feat(files): @shade/files 0.3.0 — E2EE filesystem RPC primitive
Some checks failed
Test / test (push) Has been cancelled

M-Files-1..6 land the full files-RPC layer + everything 0.3.0 needs to
ship. Apps keep their own UI; this layer ships the typed RPC, the
streams bridge for content I/O, and production hooks (rate limit,
retention, fingerprint gate, metrics).

@shade/files (NEW)
- Standard ops: list/stat/mkdir/delete/move/read/write/getThumbnail with
  Zod-validated wire schemas + clean user-handler types.
- Custom ops: typed via TypeScript declaration merging on CustomOpsMap
  + per-op Zod schemas; client.custom('app.foo', {...}) is fully typed.
- Content I/O: inline (≤ 256 KiB plaintext) base64-in-RPC; streams
  (> 256 KiB) ride @shade/transfer via userMetadata.shadeFilesWriteId
  / shadeFilesReadStreamId correlation. Server-side TransformStream
  bridges accept inbound transfers immediately (engine rejects chunks
  that arrive before accept) and park the readable for the matching
  RPC.
- Directory ops: walk(path, opts) async-iterable depth-first walker;
  uploadDirectory()/downloadDirectory() with bounded concurrency pool
  (default 4, cap 16), aggregated progress, abort.
- Production hooks (callback-based, vendor-neutral): rate-limit (op +
  byte), idempotency cache (LRU + TTL + in-flight de-dupe), path
  policy (traversal + percent-decode hardening), fingerprint gate
  (required/optional/reject), pluggable Ed25519 sig verification with
  ±5 min replay window, onMetric sink (standard names).
- React hooks (subpath @shade/files/react): ShadeFilesProvider,
  useShadeFiles, useFileList, useFileTransfer/Upload/Download.
- Shade.files.serve(handler) + Shade.files.client(peer) high-level
  entrypoint in @shade/sdk; lazy + memoized; one handler per Shade.

Wire format bump
- @shade/proto wire VERSION 0x01 → 0x02. Length prefixes changed from
  u16 to u32. The previous u16 silently truncated payloads above
  64 KiB — a hard correctness ceiling that blocked inline file ops
  up to 256 KiB. Wire-incompatible with 0.2.x peers; new sessions
  only. Cross-platform Kotlin port (android/shade-android) updated to
  match; test-vectors/wire-format.json regenerated.

Concurrency safety
- ShadeSessionManager.encrypt/.decrypt now run under per-peer mutex.
  Concurrent decryptions of the same peer raced ratchet state
  (manifested as sporadic "Failed to decrypt — wrong key or tampered
  data" under load — surfaced once concurrent uploadDirectory pumped
  many writes in flight). Encrypt was already serialized via
  Shade.send's encryptChains; decrypt is now serialized at the
  manager layer too.

@shade/streams extension
- StreamMetadata.userMetadata?: Record<string, string> for
  application-level key/value pairs that round-trip verbatim through
  stream-init plaintext. Used by @shade/files for write/read
  correlation; available to any consumer.

@shade/sdk extension
- Shade.files getter (lazy + memoized).
- BackgroundHooks.onPruneFiles + periodic timer (default 5 min) +
  BackgroundTasks.setHook(name, fn) for runtime hook registration.

Bundles in-flight 0.2.0 work
- packages/shade-streams/, packages/shade-transfer/, related
  shade-sdk streams-bridge + shade-widgets transfer hooks were
  uncommitted prior to this session. Including them keeps the
  workspace consistent at 0.3.0 since @shade/files depends on them.

Tests
- 74 new tests in @shade/files (572 → 646 workspace pass; 0 fail;
  3× stable). Coverage spans unit (inline-threshold + concurrency),
  integration (read-write inline + streams up to 1 MiB, walk +
  upload/download directory, custom-op, metrics, SDK namespace
  end-to-end), and security (tampered-envelope sig verification,
  replay window, fingerprint gate, rate-limit + quota).

Release artifacts
- All packages bumped to 0.3.0 via scripts/bump-version.ts.
- scripts/publish-all.ts PACKAGES updated with shade-files in
  topological order (after shade-transfer, before shade-sdk).
- bun run publish:dry clean (14 packed, 0 failed).
- examples/08-files-browser/ — three-process CLI demo (prekey + Bob
  server + Alice CLI) covering list/stat/mkdir/delete/upload/download.
- docs/files.md — full API + design doc.
- CHANGELOG.md 0.3.0 entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 14:00:01 +02:00
parent 7e0f7320a9
commit fa770d3063
198 changed files with 20412 additions and 256 deletions

View File

@@ -0,0 +1,41 @@
{
"name": "@shade/files",
"version": "0.3.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./react": {
"types": "./src/react/index.ts",
"import": "./src/react/index.ts"
}
},
"dependencies": {
"@shade/core": "workspace:*",
"@shade/crypto-web": "workspace:*",
"@shade/proto": "workspace:*",
"@shade/sdk": "workspace:*",
"@shade/streams": "workspace:*",
"@shade/transfer": "workspace:*",
"zod": "^3.23.8"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
},
"devDependencies": {
"@shade/server": "workspace:*",
"@types/react": "^19.2.14",
"fast-check": "^3.22.0",
"happy-dom": "^15.11.7",
"react": "^19.2.5"
}
}

View File

@@ -0,0 +1,483 @@
import type { Shade } from '@shade/sdk';
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,
MUTATION_OPS,
type StandardOp,
} from '../protocol/kinds.js';
import type { CustomOpsMap } from '../server/custom-ops.js';
import { generateIdempotencyKey, generateRequestId } from '../protocol/correlate.js';
import {
base64ToBytes,
bytesToBase64,
canonicalRpcBytes,
hashArgs,
} from '../protocol/canonical.js';
import { ShadeFileRpcChannel } from '../rpc/channel.js';
import { PendingRpcRegistry, type RegisterOptions } from '../rpc/pending.js';
import type { RpcRequest } from '../schemas/envelope.js';
import {
CustomArgsSchema,
CustomResultSchema,
DeleteArgsSchema,
DeleteResultSchema,
GetThumbnailArgsSchema,
GetThumbnailResultSchema,
ListArgsSchema,
ListResultSchema,
MkdirArgsSchema,
MkdirResultSchema,
MoveArgsSchema,
MoveResultSchema,
ReadArgsSchema,
ReadResultSchema,
StatArgsSchema,
StatResultSchema,
WriteArgsSchema,
WriteResultSchema,
type DeleteArgs,
type DeleteResult,
type GetThumbnailArgs,
type ListArgs,
type ListResult,
type MkdirArgs,
type MkdirResult,
type MoveArgs,
type MoveResult,
type ReadArgs,
type StatResult,
type ThumbnailSize,
type WriteResult,
} from '../schemas/ops.js';
import { ConflictError, InternalFileError } from '../schemas/errors.js';
import { decideInline, INLINE_THRESHOLD, type WriteSource } from './inline-threshold.js';
import type { ClientStreamsBridge } from './streams-bridge.js';
export interface BaseOpts {
signal?: AbortSignal;
/** Auto-generated for mutations if not provided. */
idempotencyKey?: string;
/** Per-call timeout. Default 30_000 ms. */
timeoutMs?: number;
}
// ─── read/write public types ─────────────────────────────────
export interface ReadInlineOutput {
kind: 'inline';
bytes: Uint8Array;
size: number;
sha256: string;
contentType?: string;
}
export interface ReadStreamsOutput {
kind: 'streams';
stream: ReadableStream<Uint8Array>;
size: number;
sha256: string;
contentType?: string;
/** Resolves once the entire transfer has been received and verified. */
done(): Promise<void>;
}
export type ReadOutput = ReadInlineOutput | ReadStreamsOutput;
export interface WriteOpts extends BaseOpts {
contentType?: string;
overwrite?: boolean;
/** Force inline even if size > 256 KiB. Throws if input is too big. */
forceInline?: boolean;
}
export interface ReadOpts extends BaseOpts {
range?: { start: number; end: number };
preferInline?: boolean;
}
export interface ThumbnailResult {
bytes: Uint8Array;
format: 'png' | 'webp' | 'jpeg';
width: number;
height: number;
sha256: string;
}
// ─── FileClient interface ────────────────────────────────────
/**
* Untyped fallback for `FileClient.custom()` — used when the consumer
* hasn't extended `CustomOpsMap` for a given op name.
*/
type CustomOpArgs<K> = K extends keyof CustomOpsMap
? CustomOpsMap[K] extends { args: infer A }
? A
: unknown
: unknown;
type CustomOpResponse<K> = K extends keyof CustomOpsMap
? CustomOpsMap[K] extends { response: infer R }
? R
: unknown
: unknown;
export interface FileClient {
list(path: string, opts?: BaseOpts & Partial<Omit<ListArgs, 'path'>>): Promise<ListResult>;
stat(path: string, opts?: BaseOpts): Promise<StatResult>;
mkdir(path: string, opts?: BaseOpts & Partial<Omit<MkdirArgs, 'path'>>): Promise<MkdirResult>;
delete(path: string, opts?: BaseOpts & Partial<Omit<DeleteArgs, 'path'>>): Promise<DeleteResult>;
move(src: string, dst: string, opts?: BaseOpts & Partial<Omit<MoveArgs, 'src' | 'dst'>>): Promise<MoveResult>;
read(path: string, opts?: ReadOpts): Promise<ReadOutput>;
write(path: string, input: WriteSource, opts?: WriteOpts): Promise<WriteResult>;
getThumbnail(
path: string,
size: ThumbnailSize,
opts?: BaseOpts & { format?: 'png' | 'webp' | 'jpeg' },
): Promise<ThumbnailResult>;
/**
* Invoke a custom op registered on the server. Args/response types are
* pulled from `CustomOpsMap` via TypeScript declaration merging — see
* `server/custom-ops.ts` for the registration pattern.
*/
custom<K extends keyof CustomOpsMap | string>(
name: K & string,
args: CustomOpArgs<K>,
opts?: BaseOpts,
): Promise<CustomOpResponse<K>>;
close(): void;
}
export interface CreateFileClientOptions {
/** Default per-call timeout. Default 30_000. */
defaultTimeoutMs?: number;
/** Hard deadline for incoming-read awaits. Default 60_000. */
ioTimeoutMs?: number;
/**
* Required for read/write `streams` ops. Coordinates inbound/outbound
* `@shade/transfer` transfers via `userMetadata.shadeFiles*Id` keys.
*/
streamsBridge?: ClientStreamsBridge;
/**
* Optional: sign the canonical bytes of every outgoing RPC envelope.
* Pluggable so apps can plug their own signing-key store (e.g.,
* Ed25519-as-a-service, browser SubtleCrypto). When omitted, ships
* `'unsigned'` — the server's `verifySender` should also be unset, or
* be configured to accept the placeholder.
*/
signRequest?: (canonicalBytes: Uint8Array) => Promise<string> | string;
}
/**
* Client-side proxy for `@shade/files` ops. Each method ships an
* `RpcRequest` over `Shade.send`/`Shade.receive` and awaits the matching
* response (or error/timeout) from `PendingRpcRegistry`.
*
* Mutations auto-generate an idempotency key per logical call (not per
* attempt) so transparent retries under the SDK don't produce duplicates.
*
* Read/write content I/O over 256 KiB requires a `streamsBridge` to be
* passed via options — it coordinates the inbound/outbound `@shade/transfer`
* transfers that carry the actual bytes.
*/
export function createFileClient(
shade: Shade,
channel: ShadeFileRpcChannel,
pending: PendingRpcRegistry,
peerAddress: string,
options: CreateFileClientOptions = {},
): FileClient {
const defaultTimeout = options.defaultTimeoutMs ?? 30_000;
const ioTimeoutMs = options.ioTimeoutMs ?? 60_000;
const streamsBridge = options.streamsBridge;
const signRequest = options.signRequest;
const senderAddress = shade.myAddress;
async function request<TResult>(
kind: string,
op: StandardOp | 'custom',
args: unknown,
opts: BaseOpts | undefined,
): Promise<TResult> {
const requestId = generateRequestId();
const isMutation = MUTATION_OPS.has(op);
const idempotencyKey =
opts?.idempotencyKey ?? (isMutation ? generateIdempotencyKey() : undefined);
const signedAt = Date.now();
let sig = 'unsigned';
if (signRequest !== undefined) {
// Server reconstructs canonical bytes using `address = from`, which
// is OUR own address as authenticated by the ratchet. So we sign
// over the same identifier here.
const canonical = canonicalRpcBytes({
address: senderAddress,
signedAt,
kind,
id: requestId,
argsHash: hashArgs(args),
});
sig = await signRequest(canonical);
}
const env: RpcRequest = {
kind,
id: requestId,
args,
...(idempotencyKey !== undefined ? { idempotencyKey } : {}),
sig,
signedAt,
};
const registerOpts: RegisterOptions = {
timeoutMs: opts?.timeoutMs ?? defaultTimeout,
onCancel: (reason) => {
// Fire-and-forget cancel envelope so server can release resources.
void channel
.send(peerAddress, {
kind: 'shade.fs.cancel/v1',
id: requestId,
reason,
})
.catch(() => {
/* swallow — cancellation is best-effort */
});
},
};
if (opts?.signal !== undefined) registerOpts.signal = opts.signal;
const pendingPromise = pending.register<TResult>(requestId, registerOpts);
try {
await channel.send(peerAddress, env);
} catch (err) {
// If the send itself fails, the pending entry will never resolve;
// reject it directly.
pending.rejectAll(err);
throw err;
}
return pendingPromise;
}
return {
async list(path, opts) {
const args: ListArgs = ListArgsSchema.parse({
path,
...(opts?.cursor !== undefined ? { cursor: opts.cursor } : {}),
...(opts?.pageSize !== undefined ? { pageSize: opts.pageSize } : {}),
...(opts?.filter !== undefined ? { filter: opts.filter } : {}),
});
const raw = await request<unknown>(KIND_LIST_V1, 'list', args, opts);
return ListResultSchema.parse(raw);
},
async stat(path, opts) {
const args = StatArgsSchema.parse({ path });
const raw = await request<unknown>(KIND_STAT_V1, 'stat', args, opts);
return StatResultSchema.parse(raw);
},
async mkdir(path, opts) {
const args = MkdirArgsSchema.parse({
path,
...(opts?.recursive !== undefined ? { recursive: opts.recursive } : {}),
});
const raw = await request<unknown>(KIND_MKDIR_V1, 'mkdir', args, opts);
return MkdirResultSchema.parse(raw);
},
async delete(path, opts) {
const args = DeleteArgsSchema.parse({
path,
...(opts?.recursive !== undefined ? { recursive: opts.recursive } : {}),
});
const raw = await request<unknown>(KIND_DELETE_V1, 'delete', args, opts);
return DeleteResultSchema.parse(raw);
},
async move(src, dst, opts) {
const args = MoveArgsSchema.parse({
src,
dst,
...(opts?.overwrite !== undefined ? { overwrite: opts.overwrite } : {}),
});
const raw = await request<unknown>(KIND_MOVE_V1, 'move', args, opts);
return MoveResultSchema.parse(raw);
},
async read(path, opts) {
const args: ReadArgs = ReadArgsSchema.parse({
path,
...(opts?.range !== undefined ? { range: opts.range } : {}),
...(opts?.preferInline !== undefined ? { preferInline: opts.preferInline } : {}),
});
const raw = await request<unknown>(KIND_READ_V1, 'read', args, opts);
const wire = ReadResultSchema.parse(raw);
if (wire.kind === 'inline') {
const bytes = base64ToBytes(wire.bytesB64);
const out: ReadInlineOutput = {
kind: 'inline',
bytes,
size: wire.size,
sha256: wire.sha256,
...(wire.contentType !== undefined ? { contentType: wire.contentType } : {}),
};
return out;
}
// streams — wait for the matching incoming transfer via the bridge.
if (streamsBridge === undefined) {
throw new InternalFileError(
'streams-bridge not configured: cannot consume streamed read',
);
}
const bridgeSignal = opts?.signal ?? new AbortController().signal;
const parked = await streamsBridge.awaitRead(wire.streamId, {
expectedFrom: peerAddress,
signal: bridgeSignal,
timeoutMs: ioTimeoutMs,
});
const out: ReadStreamsOutput = {
kind: 'streams',
stream: parked.readable,
size: wire.size,
sha256: wire.sha256,
...(wire.contentType !== undefined ? { contentType: wire.contentType } : {}),
done: async () => {
await parked.done;
},
};
return out;
},
async write(path, input, opts) {
const decision = await decideInline(input);
const overwrite = opts?.overwrite ?? false;
const contentType = opts?.contentType ?? decision.contentType;
if (decision.kind === 'inline' || opts?.forceInline === true) {
// Inline path — base64 in the RPC envelope.
const bytes =
decision.kind === 'inline'
? decision.bytes
: await drainToUint8Array(decision.stream, decision.size ?? Number.POSITIVE_INFINITY);
if (bytes.byteLength > INLINE_THRESHOLD && opts?.forceInline !== true) {
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,
});
const raw = await request<unknown>(KIND_WRITE_V1, 'write', args, opts);
return WriteResultSchema.parse(raw);
}
// Streams path — kick the upload, then ship the RPC.
if (streamsBridge === undefined) {
throw new InternalFileError(
'streams-bridge not configured: cannot ship streamed write',
);
}
const size = decision.size;
if (size === undefined) {
throw new ConflictError(
'streams write requires a known plaintext size; pass `{ stream, size }` instead of a bare ReadableStream',
);
}
const { writeId, handle } = await streamsBridge.initiateWrite({
peer: peerAddress,
stream: decision.stream,
size,
...(contentType !== undefined ? { contentType } : {}),
name: path,
...(opts?.signal !== undefined ? { signal: opts.signal } : {}),
});
const args = WriteArgsSchema.parse({
kind: 'streams',
path,
size,
...(contentType !== undefined ? { contentType } : {}),
overwrite,
writeId,
});
try {
const [raw] = await Promise.all([
request<unknown>(KIND_WRITE_V1, 'write', args, opts),
handle.done(),
]);
return WriteResultSchema.parse(raw);
} catch (err) {
// Best-effort cancel of the transfer on RPC failure.
await handle.abort('rpc-failed').catch(() => undefined);
throw err;
}
},
async getThumbnail(path, size, opts) {
const args: GetThumbnailArgs = GetThumbnailArgsSchema.parse({
path,
size,
...(opts?.format !== undefined ? { format: opts.format } : {}),
});
const raw = await request<unknown>(KIND_GET_THUMBNAIL_V1, 'getThumbnail', args, opts);
const wire = GetThumbnailResultSchema.parse(raw);
return {
bytes: base64ToBytes(wire.bytesB64),
format: wire.format,
width: wire.width,
height: wire.height,
sha256: wire.sha256,
};
},
async custom(name, args, opts) {
const wireArgs = CustomArgsSchema.parse({ name, payload: args });
// `custom` is a mutation in the rate-limit sense; auto-key for retries.
const raw = await request<unknown>(KIND_CUSTOM_V1, 'custom' as StandardOp, wireArgs, opts);
const wire = CustomResultSchema.parse(raw);
// The result schema is `{ result: unknown }` — the inner `result` is
// already validated against the consumer's response schema on the
// server side, so we trust it here.
return wire.result as never;
},
close() {
pending.rejectAll(new Error('FileClient closed'));
},
};
}
/** Drain a stream into a single buffer; used for the inline-write fallback. */
async function drainToUint8Array(
stream: ReadableStream<Uint8Array>,
cap: number,
): Promise<Uint8Array> {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value === undefined) continue;
chunks.push(value);
total += value.byteLength;
if (total > cap) {
throw new Error(`stream produced more than declared size cap (${cap})`);
}
}
} finally {
reader.releaseLock();
}
const out = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
out.set(c, offset);
offset += c.byteLength;
}
return out;
}

View File

@@ -0,0 +1,91 @@
/**
* Bounded-concurrency async map.
*
* Pulls items from an `AsyncIterable` source (lazily — never materializing
* the whole sequence) and runs `fn` on at most `concurrency` of them at
* once. Failures bubble unless `continueOnError` is set, in which case they
* are reported via `onError` and the pool keeps draining.
*/
import { CancelledError } from '../schemas/errors.js';
export interface ConcurrentMapOptions<T> {
concurrency: number;
signal?: AbortSignal;
continueOnError?: boolean;
onError?: (item: T, err: unknown) => void;
}
export async function runWithConcurrency<T>(
source: AsyncIterable<T>,
fn: (item: T) => Promise<void>,
opts: ConcurrentMapOptions<T>,
): Promise<void> {
if (opts.concurrency < 1) throw new Error('concurrency must be ≥ 1');
const iter = source[Symbol.asyncIterator]();
const inFlight = new Set<Promise<void>>();
let firstError: unknown = null;
let aborted = false;
function checkAbort(): void {
if (opts.signal?.aborted) {
aborted = true;
const reason = opts.signal.reason;
throw reason instanceof Error ? reason : new CancelledError(String(reason ?? 'aborted'));
}
}
async function pumpOne(): Promise<boolean> {
checkAbort();
const next = await iter.next();
if (next.done === true) return false;
const item = next.value;
const task = (async () => {
try {
await fn(item);
} catch (err) {
if (opts.continueOnError === true) {
opts.onError?.(item, err);
} else if (firstError === null) {
firstError = err;
}
}
})().finally(() => {
inFlight.delete(task);
});
inFlight.add(task);
return true;
}
try {
// Prime the pool
while (inFlight.size < opts.concurrency) {
if (firstError !== null || aborted) break;
const more = await pumpOne();
if (!more) break;
}
// Maintain saturation
while (inFlight.size > 0) {
if (firstError !== null && opts.continueOnError !== true) break;
await Promise.race(inFlight);
while (
inFlight.size < opts.concurrency &&
firstError === null &&
!aborted
) {
const more = await pumpOne();
if (!more) break;
}
}
// Drain whatever survived
if (inFlight.size > 0) await Promise.allSettled(inFlight);
} finally {
if (typeof iter.return === 'function') {
await iter.return().catch(() => undefined);
}
}
if (firstError !== null && opts.continueOnError !== true) {
throw firstError;
}
}

View File

@@ -0,0 +1,97 @@
/**
* Public types for directory operations (`walk`, `uploadDirectory`,
* `downloadDirectory`).
*
* `DirectoryHandleLike` and `FileHandleLike` are structurally compatible
* with the browser File System Access API
* (`FileSystemDirectoryHandle` / `FileSystemFileHandle`) so that browser
* consumers can pass them directly. For Bun/Node, use the adapter from
* `node-directory-handle.ts`.
*/
export interface FileHandleLike {
readonly kind: 'file';
readonly name: string;
/** Read-side accessor — used by `uploadDirectory`. */
getFile(): Promise<FileLike>;
/** Write-side accessor — used by `downloadDirectory`. */
createWritable(): Promise<WritableStreamLike>;
}
export interface FileLike {
readonly name: string;
readonly size: number;
readonly type: string;
stream(): ReadableStream<Uint8Array>;
arrayBuffer(): Promise<ArrayBuffer>;
}
export interface WritableStreamLike {
write(chunk: Uint8Array): Promise<void>;
close(): Promise<void>;
abort(reason?: unknown): Promise<void>;
}
export interface DirectoryHandleLike {
readonly kind: 'directory';
readonly name: string;
/** Yield child entries (file or directory). Used by `uploadDirectory`. */
entries(): AsyncIterable<[string, DirectoryHandleLike | FileHandleLike]>;
/** Used by `downloadDirectory` to create remote → local mapping. */
getDirectoryHandle(name: string, opts?: { create?: boolean }): Promise<DirectoryHandleLike>;
getFileHandle(name: string, opts?: { create?: boolean }): Promise<FileHandleLike>;
}
// ─── Bulk transfer types ─────────────────────────────────────
export type BulkTransferEvent =
| { type: 'plan'; totalFiles: number; totalBytes: number | undefined }
| { type: 'file-start'; path: string; size: number }
| { type: 'file-progress'; path: string; bytesDone: number; bytesTotal: number }
| { type: 'file-done'; path: string; bytesDone: number }
| { type: 'file-error'; path: string; error: unknown }
| {
type: 'progress';
filesDone: number;
filesTotal: number;
bytesDone: number;
bytesTotal: number | undefined;
}
| { type: 'complete'; filesDone: number; bytesDone: number; durationMs: number }
| { type: 'abort'; reason: string };
export interface BulkTransferResult {
filesDone: number;
bytesDone: number;
durationMs: number;
}
export interface BulkTransferHandle {
/** Async-iterable event stream — plan, per-file events, aggregate progress. */
readonly events: AsyncIterable<BulkTransferEvent>;
abort(reason?: string): Promise<void>;
done(): Promise<BulkTransferResult>;
}
export interface BulkOpts {
/**
* Max files in flight at once. Default 4. Capped at 16 to bound memory
* (each in-flight file may hold a buffered chunk; with 1 MiB chunks +
* 16 lanes that's still bounded).
*/
concurrency?: number;
/**
* Continue past per-file failures. Default false (fail-fast).
* When true, errors are emitted via `file-error` events and the whole
* transfer still resolves (with `done()` returning the count of
* successful files).
*/
continueOnError?: boolean;
/** Cancellation. */
signal?: AbortSignal;
}
/** Hard cap to bound memory regardless of caller-supplied concurrency. */
export const MAX_BULK_CONCURRENCY = 16;
/** Default concurrency. */
export const DEFAULT_BULK_CONCURRENCY = 4;

View File

@@ -0,0 +1,316 @@
/**
* Download an entire remote directory tree to a local `DirectoryHandleLike`.
*
* Mirror image of `uploadDirectory`: walks the remote tree via the shared
* `walk()` helper, creates local directories on the fly, and downloads each
* file with `client.read` (which routes inline or streams based on the
* server's response). Bounded concurrency keeps RPC inflight count low.
*/
import { walk, type WalkItem } from './walk.js';
import { runWithConcurrency } from './concurrency.js';
import type { FileClient } from './client.js';
import {
DEFAULT_BULK_CONCURRENCY,
MAX_BULK_CONCURRENCY,
type BulkOpts,
type BulkTransferEvent,
type BulkTransferHandle,
type BulkTransferResult,
type DirectoryHandleLike,
type FileHandleLike,
} from './directory-types.js';
import { CancelledError } from '../schemas/errors.js';
export interface DownloadDirectoryOptions extends BulkOpts {
/** Page size hint forwarded to the underlying `walk`. Default 200. */
pageSize?: number;
/** Skip files already present locally. Default false (overwrite). */
skipExisting?: boolean;
}
export function downloadDirectory(
client: Pick<FileClient, 'read' | 'list'>,
remoteRoot: string,
local: DirectoryHandleLike,
opts: DownloadDirectoryOptions = {},
): BulkTransferHandle {
const concurrency = Math.max(
1,
Math.min(MAX_BULK_CONCURRENCY, opts.concurrency ?? DEFAULT_BULK_CONCURRENCY),
);
const continueOnError = opts.continueOnError ?? false;
const externalSignal = opts.signal;
const pageSize = opts.pageSize ?? 200;
const internalAbort = new AbortController();
const combinedSignal = mergeSignals(externalSignal, internalAbort.signal);
const events: BulkTransferEvent[] = [];
const eventResolvers: ((v: IteratorResult<BulkTransferEvent>) => void)[] = [];
let eventsClosed = false;
function emit(event: BulkTransferEvent): void {
if (eventsClosed) return;
if (eventResolvers.length > 0) {
eventResolvers.shift()!({ value: event, done: false });
} else {
events.push(event);
}
}
function closeEvents(): void {
if (eventsClosed) return;
eventsClosed = true;
while (eventResolvers.length > 0) {
eventResolvers.shift()!({ value: undefined as never, done: true });
}
}
let resolveDone!: (r: BulkTransferResult) => void;
let rejectDone!: (err: unknown) => void;
const donePromise = new Promise<BulkTransferResult>((resolve, reject) => {
resolveDone = resolve;
rejectDone = reject;
});
const startedAt = Date.now();
let filesDone = 0;
let bytesDone = 0;
void (async () => {
try {
// 1. Plan: walk the remote tree into [dirs, files]
const dirItems: WalkItem[] = [];
const fileItems: WalkItem[] = [];
try {
for await (const item of walk(client, remoteRoot, {
pageSize,
...(combinedSignal !== undefined ? { signal: combinedSignal } : {}),
})) {
if (item.entry.kind === 'dir') dirItems.push(item);
else fileItems.push(item);
}
} catch (err) {
emit({ type: 'abort', reason: errMsg(err) });
closeEvents();
rejectDone(err);
return;
}
const totalFiles = fileItems.length;
const bytesTotal = fileItems.reduce((acc, f) => acc + f.entry.size, 0);
emit({ type: 'plan', totalFiles, totalBytes: bytesTotal });
if (combinedSignal.aborted) {
emit({ type: 'abort', reason: errMsg(combinedSignal.reason) });
closeEvents();
rejectDone(combinedSignal.reason ?? new CancelledError());
return;
}
// 2. Pre-create local directories sequentially.
for (const d of dirItems) {
if (combinedSignal.aborted) break;
try {
await ensureLocalDir(local, d.relativePath);
} catch (err) {
if (!continueOnError) {
emit({ type: 'abort', reason: errMsg(err) });
closeEvents();
rejectDone(err);
return;
}
}
}
// 3. Download files with bounded concurrency.
try {
await runWithConcurrency(
asyncIterableOf(fileItems),
async (item) => {
if (combinedSignal.aborted) {
throw new CancelledError('download aborted');
}
emit({ type: 'file-start', path: item.relativePath, size: item.entry.size });
try {
const fileHandle = await ensureLocalFile(local, item.relativePath);
if (opts.skipExisting === true) {
// Skip if file already exists with non-zero size
try {
const existing = await fileHandle.getFile();
if (existing.size === item.entry.size) {
filesDone++;
bytesDone += existing.size;
emit({ type: 'file-done', path: item.relativePath, bytesDone: existing.size });
emit({
type: 'progress',
filesDone,
filesTotal: totalFiles,
bytesDone,
bytesTotal,
});
return;
}
} catch {
/* not present yet — fall through to download */
}
}
const writable = await fileHandle.createWritable();
try {
const result = await client.read(item.absolutePath, {
signal: combinedSignal,
});
if (result.kind === 'inline') {
if (result.bytes.byteLength > 0) await writable.write(result.bytes);
await writable.close();
filesDone++;
bytesDone += result.bytes.byteLength;
emit({ type: 'file-done', path: item.relativePath, bytesDone: result.bytes.byteLength });
} else {
await pipeReadableToWritable(result.stream, writable);
await writable.close();
await result.done();
filesDone++;
bytesDone += result.size;
emit({ type: 'file-done', path: item.relativePath, bytesDone: result.size });
}
emit({
type: 'progress',
filesDone,
filesTotal: totalFiles,
bytesDone,
bytesTotal,
});
} catch (err) {
await writable.abort(err).catch(() => undefined);
throw err;
}
} catch (err) {
emit({ type: 'file-error', path: item.relativePath, error: err });
throw err;
}
},
{
concurrency,
signal: combinedSignal,
continueOnError,
onError: () => {
/* already emitted as 'file-error' */
},
},
);
} catch (err) {
emit({ type: 'abort', reason: errMsg(err) });
closeEvents();
rejectDone(err);
return;
}
const durationMs = Date.now() - startedAt;
emit({ type: 'complete', filesDone, bytesDone, durationMs });
closeEvents();
resolveDone({ filesDone, bytesDone, durationMs });
} catch (err) {
closeEvents();
rejectDone(err);
}
})();
donePromise.catch(() => {
/* deliberate */
});
return {
events: {
[Symbol.asyncIterator]() {
return {
next(): Promise<IteratorResult<BulkTransferEvent>> {
if (events.length > 0) {
return Promise.resolve({ value: events.shift()!, done: false });
}
if (eventsClosed) {
return Promise.resolve({ value: undefined as never, done: true });
}
return new Promise((resolve) => eventResolvers.push(resolve));
},
return(): Promise<IteratorResult<BulkTransferEvent>> {
return Promise.resolve({ value: undefined as never, done: true });
},
};
},
},
async abort(reason) {
internalAbort.abort(new CancelledError(reason ?? 'manual abort'));
},
done: () => donePromise,
};
}
// ─── Helpers ─────────────────────────────────────────────────
async function ensureLocalDir(
root: DirectoryHandleLike,
relativePath: string,
): Promise<DirectoryHandleLike> {
const segments = relativePath.split('/').filter((s) => s !== '');
let current = root;
for (const seg of segments) {
current = await current.getDirectoryHandle(seg, { create: true });
}
return current;
}
async function ensureLocalFile(
root: DirectoryHandleLike,
relativePath: string,
): Promise<FileHandleLike> {
const segments = relativePath.split('/').filter((s) => s !== '');
if (segments.length === 0) throw new Error('empty file path');
let current = root;
for (let i = 0; i < segments.length - 1; i++) {
current = await current.getDirectoryHandle(segments[i]!, { create: true });
}
return await current.getFileHandle(segments[segments.length - 1]!, { create: true });
}
async function pipeReadableToWritable(
readable: ReadableStream<Uint8Array>,
writable: { write(chunk: Uint8Array): Promise<void> },
): Promise<void> {
const reader = readable.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value !== undefined && value.byteLength > 0) {
await writable.write(value);
}
}
} finally {
reader.releaseLock();
}
}
async function* asyncIterableOf<T>(arr: T[]): AsyncIterable<T> {
for (const item of arr) yield item;
}
function errMsg(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err ?? 'unknown error');
}
function mergeSignals(
external: AbortSignal | undefined,
internal: AbortSignal,
): AbortSignal {
if (external === undefined) return internal;
if (external.aborted) return external;
const controller = new AbortController();
const onAbort = (sig: AbortSignal): void => {
controller.abort(sig.reason);
};
external.addEventListener('abort', () => onAbort(external), { once: true });
internal.addEventListener('abort', () => onAbort(internal), { once: true });
return controller.signal;
}

View File

@@ -0,0 +1,218 @@
/**
* Decide whether a write should travel inline (base64 inside the RPC
* envelope) or via a dedicated `@shade/transfer` stream.
*
* Threshold: 256 KiB plaintext. Anything strictly above goes through the
* stream path; anything ≤ goes inline.
*
* For known-size inputs (`Uint8Array`, `Blob`, `File`) the decision is
* cheap: compare `byteLength`/`size` against the threshold.
*
* For `ReadableStream` we cannot know the size up front. We pull chunks
* into a temporary buffer until we either:
* - hit EOF before the threshold → inline (we have all the bytes)
* - cross the threshold → streams (the buffered prefix + the rest of the
* stream are returned via a fresh `ReadableStream` so the caller can
* feed it to `shade.upload`).
*/
/** Plaintext size at which inline transitions to streams. */
export const INLINE_THRESHOLD = 256 * 1024;
export type InlineDecision =
| {
kind: 'inline';
bytes: Uint8Array;
contentType?: string;
}
| {
kind: 'streams';
stream: ReadableStream<Uint8Array>;
/** Plaintext size when known (Blob/File). undefined for raw streams. */
size?: number;
contentType?: string;
};
/**
* Public input shape for `FileClient.write`. Runtime discriminator helper.
*/
export type WriteSource =
| Uint8Array
| Blob
| File
| ReadableStream<Uint8Array>
| { stream: ReadableStream<Uint8Array>; size: number; contentType?: string };
export async function decideInline(input: WriteSource): Promise<InlineDecision> {
// 1. Uint8Array — direct size check
if (input instanceof Uint8Array) {
if (input.byteLength <= INLINE_THRESHOLD) {
return { kind: 'inline', bytes: input };
}
return {
kind: 'streams',
stream: uint8ArrayToStream(input),
size: input.byteLength,
};
}
// 2. Blob / File — known size (Blob.type is a string; File extends Blob)
if (typeof Blob !== 'undefined' && input instanceof Blob) {
const blob: Blob = input;
const contentType = blob.type === '' ? undefined : blob.type;
if (blob.size <= INLINE_THRESHOLD) {
const bytes = new Uint8Array(await blob.arrayBuffer());
return contentType !== undefined
? { kind: 'inline', bytes, contentType }
: { kind: 'inline', bytes };
}
return contentType !== undefined
? { kind: 'streams', stream: blob.stream(), size: blob.size, contentType }
: { kind: 'streams', stream: blob.stream(), size: blob.size };
}
// 3. Pre-wrapped { stream, size } — trust caller's declared size
if (
typeof input === 'object' &&
input !== null &&
'stream' in input &&
'size' in input &&
(input as { stream: unknown }).stream instanceof ReadableStream
) {
const wrapped = input as { stream: ReadableStream<Uint8Array>; size: number; contentType?: string };
if (wrapped.size <= INLINE_THRESHOLD) {
const bytes = await drainStream(wrapped.stream, wrapped.size);
const contentType = wrapped.contentType;
return contentType !== undefined
? { kind: 'inline', bytes, contentType }
: { kind: 'inline', bytes };
}
const contentType = wrapped.contentType;
return contentType !== undefined
? { kind: 'streams', stream: wrapped.stream, size: wrapped.size, contentType }
: { kind: 'streams', stream: wrapped.stream, size: wrapped.size };
}
// 4. Bare ReadableStream — peek until threshold or EOF
if (input instanceof ReadableStream) {
return await peekStream(input);
}
throw new TypeError(
`decideInline: unsupported input type ${Object.prototype.toString.call(input)}`,
);
}
/**
* Drain a stream into a Uint8Array, with a soft cap at `expected` + slack.
* Used for the inline path when the caller declared a size.
*/
async function drainStream(
stream: ReadableStream<Uint8Array>,
expected: number,
): Promise<Uint8Array> {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value === undefined) continue;
chunks.push(value);
total += value.byteLength;
if (total > expected + INLINE_THRESHOLD) {
throw new Error(
`decideInline: stream produced more bytes (${total}) than declared size (${expected})`,
);
}
}
} finally {
reader.releaseLock();
}
return concat(chunks, total);
}
/**
* Peek a `ReadableStream` of unknown length: buffer up to `INLINE_THRESHOLD + 1`
* bytes. If EOF first, return inline. Otherwise reconstruct a stream that
* yields the buffered prefix followed by the remainder.
*/
async function peekStream(stream: ReadableStream<Uint8Array>): Promise<InlineDecision> {
const reader = stream.getReader();
const buffered: Uint8Array[] = [];
let total = 0;
try {
while (total <= INLINE_THRESHOLD) {
const { value, done } = await reader.read();
if (done) {
reader.releaseLock();
return { kind: 'inline', bytes: concat(buffered, total) };
}
if (value === undefined) continue;
buffered.push(value);
total += value.byteLength;
}
// We have at least INLINE_THRESHOLD + 1 bytes buffered. Promote to streams.
const reconstructed = reconstructStream(buffered, reader);
return { kind: 'streams', stream: reconstructed };
} catch (err) {
reader.releaseLock();
throw err;
}
}
interface MinimalReader {
read(): Promise<{ value: Uint8Array | undefined; done: boolean }>;
cancel(reason?: unknown): Promise<void>;
releaseLock(): void;
}
function reconstructStream(
prefix: Uint8Array[],
reader: MinimalReader,
): ReadableStream<Uint8Array> {
let prefixIdx = 0;
return new ReadableStream<Uint8Array>({
async pull(controller) {
if (prefixIdx < prefix.length) {
controller.enqueue(prefix[prefixIdx]!);
prefixIdx++;
return;
}
const { value, done } = await reader.read();
if (done) {
controller.close();
reader.releaseLock();
return;
}
if (value !== undefined) controller.enqueue(value);
},
async cancel(reason) {
try {
await reader.cancel(reason);
} finally {
reader.releaseLock();
}
},
});
}
function uint8ArrayToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes);
controller.close();
},
});
}
function concat(chunks: Uint8Array[], total: number): Uint8Array {
const out = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
out.set(c, offset);
offset += c.byteLength;
}
return out;
}

View File

@@ -0,0 +1,145 @@
/**
* In-memory `DirectoryHandleLike` implementation.
*
* Useful for tests and Node/Bun environments without the browser File
* System Access API. The shape is intentionally compatible with browser
* `FileSystemDirectoryHandle` so consumers can swap implementations.
*/
import type {
DirectoryHandleLike,
FileHandleLike,
FileLike,
WritableStreamLike,
} from './directory-types.js';
interface MemoryNode {
bytes: Uint8Array;
type: string;
}
class MemoryFileLike implements FileLike {
constructor(
public readonly name: string,
private readonly node: MemoryNode,
) {}
get size(): number { return this.node.bytes.byteLength; }
get type(): string { return this.node.type; }
stream(): ReadableStream<Uint8Array> {
const bytes = this.node.bytes;
return new ReadableStream<Uint8Array>({
start(controller) {
if (bytes.byteLength > 0) controller.enqueue(bytes);
controller.close();
},
});
}
async arrayBuffer(): Promise<ArrayBuffer> {
const out = new ArrayBuffer(this.node.bytes.byteLength);
new Uint8Array(out).set(this.node.bytes);
return out;
}
}
class MemoryWritable implements WritableStreamLike {
private chunks: Uint8Array[] = [];
constructor(private readonly file: MemoryFile) {}
async write(chunk: Uint8Array): Promise<void> {
this.chunks.push(chunk);
}
async close(): Promise<void> {
let total = 0;
for (const c of this.chunks) total += c.byteLength;
const out = new Uint8Array(total);
let offset = 0;
for (const c of this.chunks) {
out.set(c, offset);
offset += c.byteLength;
}
this.file.commit(out);
}
async abort(): Promise<void> {
this.chunks = [];
}
}
class MemoryFile implements FileHandleLike {
readonly kind: 'file' = 'file';
constructor(
public readonly name: string,
public readonly node: MemoryNode,
) {}
async getFile(): Promise<FileLike> {
return new MemoryFileLike(this.name, this.node);
}
async createWritable(): Promise<WritableStreamLike> {
return new MemoryWritable(this);
}
/** Internal — used by `MemoryWritable.close()`. */
commit(bytes: Uint8Array): void {
this.node.bytes = bytes;
}
}
class MemoryDirectory implements DirectoryHandleLike {
readonly kind: 'directory' = 'directory';
private readonly children = new Map<string, MemoryDirectory | MemoryFile>();
constructor(public readonly name: string) {}
async *entries(): AsyncIterable<[string, DirectoryHandleLike | FileHandleLike]> {
for (const [name, child] of this.children) {
yield [name, child];
}
}
async getDirectoryHandle(name: string, opts?: { create?: boolean }): Promise<DirectoryHandleLike> {
const existing = this.children.get(name);
if (existing !== undefined) {
if (existing.kind !== 'directory') {
throw new Error(`'${name}' exists but is a file`);
}
return existing;
}
if (opts?.create !== true) throw new Error(`directory not found: ${name}`);
const dir = new MemoryDirectory(name);
this.children.set(name, dir);
return dir;
}
async getFileHandle(name: string, opts?: { create?: boolean }): Promise<FileHandleLike> {
const existing = this.children.get(name);
if (existing !== undefined) {
if (existing.kind !== 'file') {
throw new Error(`'${name}' exists but is a directory`);
}
return existing;
}
if (opts?.create !== true) throw new Error(`file not found: ${name}`);
const node: MemoryNode = { bytes: new Uint8Array(0), type: '' };
const file = new MemoryFile(name, node);
this.children.set(name, file);
return file;
}
/** Test helper: synchronously add a file with given bytes. */
addFile(name: string, bytes: Uint8Array, type: string = ''): MemoryFile {
const node: MemoryNode = { bytes, type };
const file = new MemoryFile(name, node);
this.children.set(name, file);
return file;
}
/** Test helper: synchronously add a subdirectory. */
addDir(name: string): MemoryDirectory {
const dir = new MemoryDirectory(name);
this.children.set(name, dir);
return dir;
}
}
/** Construct an empty in-memory directory tree. */
export function createMemoryDirectory(name: string = ''): DirectoryHandleLike & {
addFile(name: string, bytes: Uint8Array, type?: string): FileHandleLike;
addDir(name: string): DirectoryHandleLike;
} {
return new MemoryDirectory(name);
}

View File

@@ -0,0 +1,251 @@
/**
* Client-side bridge between `@shade/files` content RPC ops and the
* `@shade/transfer` engine.
*
* Two responsibilities, mirror-image of `server/streams-bridge.ts`:
* 1. **Outbound writes (client → server, > 256 KiB).** When `FileClient.write`
* promotes to the streams path, this bridge calls `shade.upload(...)`
* with `userMetadata.shadeFilesWriteId = <id>` so the server can
* correlate the inbound transfer with the parallel `write` RPC.
*
* 2. **Inbound reads (server → client, > 256 KiB).** When `FileClient.read`
* gets a `{ kind: 'streams', streamId, ... }` RPC response, it asks
* this bridge for the matching incoming transfer's plaintext stream.
* The bridge subscribes to `shade.onIncomingTransfer` once and, on
* each transfer tagged with `userMetadata.shadeFilesReadStreamId`,
* **immediately** calls `accept(...)` (the engine rejects chunks that
* arrive before accept), pipes plaintext into a TransformStream, and
* parks the readable side until the matching read RPC awaits it.
*/
import type { TransferHandle, TransferProgress } from '@shade/transfer';
import { generateRequestId } from '../protocol/correlate.js';
import { OperationTimeoutError } from '../schemas/errors.js';
import {
META_KEY_READ_STREAM_ID,
META_KEY_WRITE_ID,
type StreamsBridgeShade,
} from '../server/streams-bridge.js';
export interface AwaitReadOptions {
/** Sender address — must match `incoming.from` for delivery. */
expectedFrom: string;
signal: AbortSignal;
/** Hard deadline (ms from now). Default 60_000. */
timeoutMs?: number;
}
export interface ParkedRead {
from: string;
/** Plaintext stream. The bridge already accepted the transfer. */
readable: ReadableStream<Uint8Array>;
/** Resolves when the transfer fully completes (verified sha256 available). */
done: Promise<{ sha256: string; bytesSent: number }>;
/** Underlying transfer handle — for abort propagation. */
handle: TransferHandle;
arrivedAt: number;
}
export interface ClientStreamsBridge {
/**
* Generate a fresh writeId, kick `shade.upload(...)` to `peer` with that
* id stamped in `userMetadata`, and return both the id (for the parallel
* RPC envelope) and the transfer handle (for `done()`/`abort()`).
*/
initiateWrite(opts: {
peer: string;
stream: ReadableStream<Uint8Array>;
size: number;
contentType?: string;
name?: string;
signal?: AbortSignal;
onProgress?: (p: TransferProgress) => void;
}): Promise<{ writeId: string; handle: TransferHandle }>;
/**
* Wait for an inbound transfer carrying `userMetadata.shadeFilesReadStreamId
* === streamId` from `expectedFrom`. Resolves with the parked entry whose
* `readable` can be consumed by the caller.
*/
awaitRead(streamId: string, opts: AwaitReadOptions): Promise<ParkedRead>;
destroy(): Promise<void>;
}
interface PendingReadWaiter {
resolve: (parked: ParkedRead) => void;
reject: (err: unknown) => void;
expectedFrom: string;
timer: ReturnType<typeof setTimeout> | null;
abortListener: (() => void) | null;
signal: AbortSignal;
}
export interface CreateClientStreamsBridgeOptions {
/** Default deadline for `awaitRead` if the caller doesn't supply one. */
defaultAwaitReadTimeoutMs?: number;
/** How long to retain a parked transfer waiting for its RPC. Default 60_000. */
parkedReadTtlMs?: number;
}
export async function createClientStreamsBridge(
shade: StreamsBridgeShade,
options: CreateClientStreamsBridgeOptions = {},
): Promise<ClientStreamsBridge> {
const parkedReadTtlMs = options.parkedReadTtlMs ?? 60_000;
const defaultAwaitTimeoutMs = options.defaultAwaitReadTimeoutMs ?? 60_000;
const parked = new Map<string, ParkedRead>();
const waiters = new Map<string, PendingReadWaiter>();
let destroyed = false;
const unsubscribe = await shade.onIncomingTransfer(async (incoming) => {
const readStreamId = incoming.metadata.userMetadata?.[META_KEY_READ_STREAM_ID];
if (readStreamId === undefined) return;
const ts = new TransformStream<Uint8Array, Uint8Array>();
let handle: TransferHandle;
try {
handle = await incoming.accept({
output: { kind: 'pipe', pipeTo: ts.writable },
});
} catch (err) {
console.error('[shade-files client streams-bridge] accept failed:', err);
return;
}
const arrival: ParkedRead = {
from: incoming.from,
readable: ts.readable,
done: handle.done().then((r) => ({ sha256: r.sha256, bytesSent: r.bytesSent })),
handle,
arrivedAt: Date.now(),
};
arrival.done.catch(() => {
/* swallow until consumer awaits */
});
const waiter = waiters.get(readStreamId);
if (waiter !== undefined) {
waiters.delete(readStreamId);
cleanupWaiter(waiter);
if (incoming.from !== waiter.expectedFrom) {
void handle.abort('sender-mismatch').catch(() => undefined);
waiter.reject(
new Error(
`streams-bridge: readStreamId=${readStreamId} delivered by ${incoming.from}, expected ${waiter.expectedFrom}`,
),
);
return;
}
waiter.resolve(arrival);
return;
}
parked.set(readStreamId, arrival);
setTimeout(() => {
const stale = parked.get(readStreamId);
if (stale === arrival) {
parked.delete(readStreamId);
void handle.abort('rpc-timeout').catch(() => undefined);
}
}, parkedReadTtlMs).unref?.();
});
function cleanupWaiter(w: PendingReadWaiter): void {
if (w.timer !== null) clearTimeout(w.timer);
if (w.abortListener !== null) {
w.signal.removeEventListener('abort', w.abortListener);
}
}
return {
async initiateWrite(opts) {
if (destroyed) throw new Error('streams-bridge: destroyed');
const writeId = generateRequestId();
const transferOpts: import('@shade/transfer').TransferOptions = {
to: opts.peer,
input: opts.stream,
metadata: {
...(opts.name !== undefined ? { name: opts.name } : {}),
...(opts.contentType !== undefined ? { contentType: opts.contentType } : {}),
sizeBytes: opts.size,
userMetadata: { [META_KEY_WRITE_ID]: writeId },
},
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
...(opts.onProgress !== undefined ? { onProgress: opts.onProgress } : {}),
};
const handle = await shade.upload(transferOpts);
return { writeId, handle };
},
async awaitRead(streamId, opts) {
if (destroyed) throw new Error('streams-bridge: destroyed');
const ready = parked.get(streamId);
if (ready !== undefined) {
parked.delete(streamId);
if (ready.from !== opts.expectedFrom) {
void ready.handle.abort('sender-mismatch').catch(() => undefined);
throw new Error(
`streams-bridge: readStreamId=${streamId} delivered by ${ready.from}, expected ${opts.expectedFrom}`,
);
}
return ready;
}
if (waiters.has(streamId)) {
throw new Error(`streams-bridge: readStreamId=${streamId} already awaited`);
}
const timeoutMs = opts.timeoutMs ?? defaultAwaitTimeoutMs;
return await new Promise<ParkedRead>((resolve, reject) => {
const w: PendingReadWaiter = {
resolve,
reject,
expectedFrom: opts.expectedFrom,
timer: null,
abortListener: null,
signal: opts.signal,
};
w.timer = setTimeout(() => {
if (waiters.get(streamId) === w) {
waiters.delete(streamId);
cleanupWaiter(w);
reject(new OperationTimeoutError(`streams-bridge: readStreamId=${streamId} timed out after ${timeoutMs}ms`));
}
}, timeoutMs);
if (opts.signal.aborted) {
cleanupWaiter(w);
reject(opts.signal.reason ?? new Error('aborted before await'));
return;
}
const onAbort = (): void => {
if (waiters.get(streamId) === w) {
waiters.delete(streamId);
cleanupWaiter(w);
reject(opts.signal.reason ?? new Error('aborted'));
}
};
w.abortListener = onAbort;
opts.signal.addEventListener('abort', onAbort, { once: true });
waiters.set(streamId, w);
});
},
async destroy() {
if (destroyed) return;
destroyed = true;
unsubscribe();
for (const w of waiters.values()) {
cleanupWaiter(w);
w.reject(new Error('streams-bridge: destroyed'));
}
waiters.clear();
for (const p of parked.values()) {
try {
await p.handle.abort('bridge-destroyed');
} catch {
/* swallow */
}
}
parked.clear();
},
};
}

View File

@@ -0,0 +1,310 @@
/**
* Upload an entire local directory tree to a remote peer via a `FileClient`.
*
* Walks the local `DirectoryHandleLike` lazily, creates remote directories
* via `client.mkdir({ recursive: true })`, and uploads each file with
* `client.write` (which routes inline or streams based on size). A bounded
* concurrency pool keeps memory and inflight RPCs in check.
*
* Returns a `BulkTransferHandle` whose `events` stream emits `'plan'`,
* `'file-start'`, `'file-progress'` (currently emitted at file start +
* end), `'file-done'` / `'file-error'`, aggregate `'progress'`, and a
* final `'complete'` (or `'abort'`).
*/
import { posixJoin } from '../utils/path.js';
import { runWithConcurrency } from './concurrency.js';
import type { FileClient } from './client.js';
import {
DEFAULT_BULK_CONCURRENCY,
MAX_BULK_CONCURRENCY,
type BulkOpts,
type BulkTransferEvent,
type BulkTransferHandle,
type BulkTransferResult,
type DirectoryHandleLike,
type FileHandleLike,
} from './directory-types.js';
import { CancelledError } from '../schemas/errors.js';
interface PlannedFile {
/** Local file handle. */
handle: FileHandleLike;
/** Path relative to the upload root. */
relativePath: string;
/** Absolute remote path. */
remoteAbsPath: string;
}
interface PlannedDir {
remoteAbsPath: string;
}
export interface UploadDirectoryOptions extends BulkOpts {
/**
* Pre-create remote directories before uploading files. Default true.
* Disable if the server-side `write` already mkdir-p's parents.
*/
precreateDirs?: boolean;
}
export function uploadDirectory(
client: Pick<FileClient, 'write' | 'mkdir'>,
local: DirectoryHandleLike,
remoteRoot: string,
opts: UploadDirectoryOptions = {},
): BulkTransferHandle {
const concurrency = Math.max(
1,
Math.min(MAX_BULK_CONCURRENCY, opts.concurrency ?? DEFAULT_BULK_CONCURRENCY),
);
const precreateDirs = opts.precreateDirs ?? true;
const continueOnError = opts.continueOnError ?? false;
const externalSignal = opts.signal;
const internalAbort = new AbortController();
const combinedSignal = mergeSignals(externalSignal, internalAbort.signal);
const events: BulkTransferEvent[] = [];
const eventResolvers: ((v: IteratorResult<BulkTransferEvent>) => void)[] = [];
let eventsClosed = false;
function emit(event: BulkTransferEvent): void {
if (eventsClosed) return;
if (eventResolvers.length > 0) {
eventResolvers.shift()!({ value: event, done: false });
} else {
events.push(event);
}
}
function closeEvents(): void {
if (eventsClosed) return;
eventsClosed = true;
while (eventResolvers.length > 0) {
eventResolvers.shift()!({ value: undefined as never, done: true });
}
}
let resolveDone!: (r: BulkTransferResult) => void;
let rejectDone!: (err: unknown) => void;
const donePromise = new Promise<BulkTransferResult>((resolve, reject) => {
resolveDone = resolve;
rejectDone = reject;
});
const startedAt = Date.now();
let filesDone = 0;
let bytesDone = 0;
let bytesTotal = 0;
// Run the upload pipeline asynchronously.
void (async () => {
try {
// 1. Plan: walk local tree, collect dirs + files
const plannedDirs: PlannedDir[] = [];
const plannedFiles: PlannedFile[] = [];
try {
await collect(local, '', remoteRoot, plannedDirs, plannedFiles, combinedSignal);
} catch (err) {
emit({ type: 'abort', reason: errMsg(err) });
closeEvents();
rejectDone(err);
return;
}
const totalFiles = plannedFiles.length;
bytesTotal = plannedFiles.reduce(
(acc, f) => acc + (f.handle as FileHandleLike & { _size?: number })._size!,
0,
);
// Note: bytesTotal is computed lazily in collect() via cached sizes.
emit({ type: 'plan', totalFiles, totalBytes: bytesTotal });
if (combinedSignal.aborted) {
emit({ type: 'abort', reason: errMsg(combinedSignal.reason) });
closeEvents();
rejectDone(combinedSignal.reason ?? new CancelledError());
return;
}
// 2. Pre-create remote directories sequentially (cheap, avoids races
// when many uploads target the same parent).
if (precreateDirs) {
for (const d of plannedDirs) {
if (combinedSignal.aborted) break;
try {
await client.mkdir(d.remoteAbsPath, { recursive: true });
} catch (err) {
if (!isAlreadyExistsError(err)) {
if (!continueOnError) {
emit({ type: 'abort', reason: errMsg(err) });
closeEvents();
rejectDone(err);
return;
}
}
}
}
}
// 3. Upload files with bounded concurrency
try {
await runWithConcurrency(
asyncIterableOf(plannedFiles),
async (planned) => {
if (combinedSignal.aborted) {
throw new CancelledError('upload aborted');
}
const file = await planned.handle.getFile();
const size = file.size;
emit({ type: 'file-start', path: planned.relativePath, size });
try {
const writeOpts: { contentType?: string; signal?: AbortSignal } = {};
if (file.type !== '') writeOpts.contentType = file.type;
writeOpts.signal = combinedSignal;
if (size === 0) {
// Edge case: empty file — write empty buffer.
await client.write(planned.remoteAbsPath, new Uint8Array(0), writeOpts);
} else {
await client.write(
planned.remoteAbsPath,
{ stream: file.stream(), size, ...(file.type !== '' ? { contentType: file.type } : {}) },
writeOpts,
);
}
filesDone++;
bytesDone += size;
emit({ type: 'file-done', path: planned.relativePath, bytesDone: size });
emit({
type: 'progress',
filesDone,
filesTotal: totalFiles,
bytesDone,
bytesTotal,
});
} catch (err) {
emit({ type: 'file-error', path: planned.relativePath, error: err });
throw err;
}
},
{
concurrency,
signal: combinedSignal,
continueOnError,
onError: () => {
/* already emitted as 'file-error' */
},
},
);
} catch (err) {
emit({ type: 'abort', reason: errMsg(err) });
closeEvents();
rejectDone(err);
return;
}
const durationMs = Date.now() - startedAt;
emit({ type: 'complete', filesDone, bytesDone, durationMs });
closeEvents();
resolveDone({ filesDone, bytesDone, durationMs });
} catch (err) {
// Belt-and-suspenders: any unhandled error reaches here.
closeEvents();
rejectDone(err);
}
})();
// Suppress unhandled-rejection until consumer awaits done().
donePromise.catch(() => {
/* deliberate */
});
return {
events: {
[Symbol.asyncIterator]() {
return {
next(): Promise<IteratorResult<BulkTransferEvent>> {
if (events.length > 0) {
return Promise.resolve({ value: events.shift()!, done: false });
}
if (eventsClosed) {
return Promise.resolve({ value: undefined as never, done: true });
}
return new Promise((resolve) => eventResolvers.push(resolve));
},
return(): Promise<IteratorResult<BulkTransferEvent>> {
// Caller broke out — stop iterating but keep the bulk going.
return Promise.resolve({ value: undefined as never, done: true });
},
};
},
},
async abort(reason) {
internalAbort.abort(new CancelledError(reason ?? 'manual abort'));
},
done: () => donePromise,
};
}
// ─── Helpers ─────────────────────────────────────────────────
async function collect(
dir: DirectoryHandleLike,
relPrefix: string,
remoteRoot: string,
plannedDirs: PlannedDir[],
plannedFiles: PlannedFile[],
signal: AbortSignal,
): Promise<void> {
for await (const [name, handle] of dir.entries()) {
if (signal.aborted) return;
const relPath = relPrefix === '' ? name : `${relPrefix}/${name}`;
if (handle.kind === 'directory') {
const remoteAbs = posixJoin(remoteRoot, relPath);
plannedDirs.push({ remoteAbsPath: remoteAbs });
await collect(handle as DirectoryHandleLike, relPath, remoteRoot, plannedDirs, plannedFiles, signal);
} else {
// Cache size on the handle so the planning total is accurate.
const file = await (handle as FileHandleLike).getFile();
(handle as FileHandleLike & { _size?: number })._size = file.size;
plannedFiles.push({
handle: handle as FileHandleLike,
relativePath: relPath,
remoteAbsPath: posixJoin(remoteRoot, relPath),
});
}
}
}
async function* asyncIterableOf<T>(arr: T[]): AsyncIterable<T> {
for (const item of arr) yield item;
}
function isAlreadyExistsError(err: unknown): boolean {
if (err === null || typeof err !== 'object') return false;
const code = (err as { code?: string; payload?: { code?: string } }).code;
if (code === 'SHADE_FS_CONFLICT') return true;
const payloadCode = (err as { payload?: { code?: string } }).payload?.code;
return payloadCode === 'CONFLICT';
}
function errMsg(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err ?? 'unknown error');
}
function mergeSignals(
external: AbortSignal | undefined,
internal: AbortSignal,
): AbortSignal {
if (external === undefined) return internal;
if (external.aborted) return external;
const controller = new AbortController();
const onAbort = (sig: AbortSignal): void => {
controller.abort(sig.reason);
};
external.addEventListener('abort', () => onAbort(external), { once: true });
internal.addEventListener('abort', () => onAbort(internal), { once: true });
return controller.signal;
}

View File

@@ -0,0 +1,89 @@
/**
* Async-iterable directory walker.
*
* Depth-first by default — yields a directory's entries before descending
* into the next sibling. Memory-bounded: never materializes the whole tree;
* uses `client.list` page-by-page.
*
* Designed for arbitrarily-large remote trees: the consumer can break out
* of the iterator at any point and the walk halts cleanly.
*/
import { posixJoin } from '../utils/path.js';
import { CancelledError } from '../schemas/errors.js';
import type { FileEntry } from '../schemas/file-entry.js';
import type { FileClient } from './client.js';
export interface WalkOpts {
/** Hard cap on recursion depth. Default 32. */
maxDepth?: number;
/** Cancellation. */
signal?: AbortSignal;
/**
* Apply a per-entry filter. Return `false` to skip an entry (and, for
* directories, to skip descending into it). Default: include all.
*/
filter?: (entry: FileEntry, relativePath: string) => boolean;
/** Page size hint for `client.list`. Default 200. */
pageSize?: number;
}
export interface WalkItem {
entry: FileEntry;
/** Path relative to the walk root. Includes the entry's own name. */
relativePath: string;
/** Absolute path on the remote (root + relativePath). */
absolutePath: string;
/** Depth from root — 1 for direct children. */
depth: number;
}
const DEFAULT_MAX_DEPTH = 32;
const DEFAULT_PAGE_SIZE = 200;
export async function* walk(
client: Pick<FileClient, 'list'>,
rootPath: string,
opts: WalkOpts = {},
): AsyncIterable<WalkItem> {
const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
const pageSize = opts.pageSize ?? DEFAULT_PAGE_SIZE;
function checkAbort(): void {
if (opts.signal?.aborted) {
throw new CancelledError(
opts.signal.reason instanceof Error
? opts.signal.reason.message
: 'walk aborted',
);
}
}
async function* descend(absDir: string, depth: number, relPrefix: string): AsyncIterable<WalkItem> {
if (depth > maxDepth) return;
let cursor: string | undefined;
do {
checkAbort();
const page = await client.list(absDir, {
pageSize,
...(cursor !== undefined ? { cursor } : {}),
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
});
for (const entry of page.entries) {
checkAbort();
const relPath = relPrefix === '' ? entry.name : `${relPrefix}/${entry.name}`;
const absPath = posixJoin(absDir, entry.name);
if (opts.filter !== undefined && !opts.filter(entry, relPath)) {
continue;
}
const item: WalkItem = { entry, relativePath: relPath, absolutePath: absPath, depth };
yield item;
if (entry.kind === 'dir') {
yield* descend(absPath, depth + 1, relPath);
}
}
cursor = page.hasMore ? page.nextCursor : undefined;
} while (cursor !== undefined);
}
yield* descend(rootPath, 1, '');
}

View File

@@ -0,0 +1,198 @@
// Schemas — Zod runtime + compile-time types
export * from './schemas/index.js';
// Protocol primitives — kinds, correlation, canonical bytes, envelope codec
export {
SHADE_FILES_VERSION,
KIND_PREFIX,
SUPPORTED_KINDS,
isSupportedKind,
} from './protocol/version.js';
export type { SupportedKind } from './protocol/version.js';
export {
KIND_LIST_V1,
KIND_STAT_V1,
KIND_MKDIR_V1,
KIND_DELETE_V1,
KIND_MOVE_V1,
KIND_READ_V1,
KIND_WRITE_V1,
KIND_GET_THUMBNAIL_V1,
KIND_CUSTOM_V1,
KIND_ERROR_V1,
KIND_CANCEL_V1,
responseKindOf,
opOfKind,
MUTATION_OPS,
} from './protocol/kinds.js';
export type { StandardOp } from './protocol/kinds.js';
export {
generateRequestId,
generateIdempotencyKey,
base64UrlEncode,
base64UrlDecode,
} from './protocol/correlate.js';
export {
canonicalRpcBytes,
canonicalJsonStringify,
hashArgs,
bytesToHex,
bytesToBase64,
base64ToBytes,
} from './protocol/canonical.js';
export {
encodeEnvelope,
looksLikeFileEnvelope,
tryParseEnvelope,
classify,
} from './protocol/envelope-codec.js';
export type { ClassifiedEnvelope } from './protocol/envelope-codec.js';
// RPC channel — wires Shade.send/onMessage to the file-RPC routing layer
export { ShadeFileRpcChannel } from './rpc/channel.js';
export type { RpcChannelHooks } from './rpc/channel.js';
export { PendingRpcRegistry } from './rpc/pending.js';
export type { RegisterOptions } from './rpc/pending.js';
// Server side
export { createFileHandler, INTERNAL_SYMBOL } from './server/handler.js';
export type {
FileHandler,
FileHandlerConfig,
FileHandlerOps,
} from './server/handler.js';
export type { OpContext, OpKind } from './server/handler-context.js';
export { validatePath } from './server/path-policy.js';
export type { PathPolicy, PathValidationResult } from './server/path-policy.js';
export { IdempotencyCache } from './server/idempotency-cache.js';
export type { IdempotencyCacheOptions } from './server/idempotency-cache.js';
export { RateLimiter } from './server/rate-limiter.js';
export type { RateLimitConfig } from './server/rate-limiter.js';
export { createCursorBuilder } from './server/cursor.js';
export type { CursorBuilder } from './server/cursor.js';
export {
createServerStreamsBridge,
META_KEY_READ_STREAM_ID,
META_KEY_WRITE_ID,
} from './server/streams-bridge.js';
export type {
ServerStreamsBridge,
StreamsBridgeShade,
CreateServerStreamsBridgeOptions,
ParkedWrite,
AwaitWriteOptions,
} from './server/streams-bridge.js';
export type {
UserReadResult,
UserReadResultInline,
UserReadResultStreams,
UserWriteArgs,
UserWriteContent,
UserWriteContentInline,
UserWriteContentStreams,
UserThumbnailResult,
} from './server/io-types.js';
export {
assertThumbnailFormat,
isThumbnailFormat,
} from './server/thumbnail.js';
export type { ThumbnailFormat } from './server/thumbnail.js';
// Client side
export { createFileClient } from './client/client.js';
export type {
FileClient,
BaseOpts,
CreateFileClientOptions,
ReadOpts,
WriteOpts,
ReadOutput,
ReadInlineOutput,
ReadStreamsOutput,
ThumbnailResult,
} from './client/client.js';
export {
createClientStreamsBridge,
} from './client/streams-bridge.js';
export type {
ClientStreamsBridge,
CreateClientStreamsBridgeOptions,
AwaitReadOptions,
ParkedRead,
} from './client/streams-bridge.js';
export {
decideInline,
INLINE_THRESHOLD,
} from './client/inline-threshold.js';
export type {
InlineDecision,
WriteSource,
} from './client/inline-threshold.js';
// Directory ops — walk + bulk transfers
export { walk } from './client/walk.js';
export type { WalkOpts, WalkItem } from './client/walk.js';
export { uploadDirectory } from './client/upload-directory.js';
export type { UploadDirectoryOptions } from './client/upload-directory.js';
export { downloadDirectory } from './client/download-directory.js';
export type { DownloadDirectoryOptions } from './client/download-directory.js';
export { runWithConcurrency } from './client/concurrency.js';
export type { ConcurrentMapOptions } from './client/concurrency.js';
export {
DEFAULT_BULK_CONCURRENCY,
MAX_BULK_CONCURRENCY,
} from './client/directory-types.js';
export type {
BulkOpts,
BulkTransferEvent,
BulkTransferHandle,
BulkTransferResult,
DirectoryHandleLike,
FileHandleLike,
FileLike,
WritableStreamLike,
} from './client/directory-types.js';
export { createMemoryDirectory } from './client/memory-directory.js';
// Custom-op registry typing (declaration-merged by consumers)
export type {
CustomOpDef,
CustomOpsMap,
CustomOpRegistration,
CustomOpRegistrations,
} from './server/custom-ops.js';
// Production hooks: metrics
export {
METRIC_BYTES_IN,
METRIC_BYTES_OUT,
METRIC_FINGERPRINT_REJECT_TOTAL,
METRIC_IDEMPOTENCY_CONFLICT_TOTAL,
METRIC_IDEMPOTENCY_HIT_TOTAL,
METRIC_OP_DURATION_MS,
METRIC_OP_TOTAL,
METRIC_RATE_LIMIT_REJECT_TOTAL,
METRIC_SIGNATURE_REJECT_TOTAL,
NOOP_METRIC_SINK,
} from './server/metrics.js';
export type { MetricSink, MetricTags } from './server/metrics.js';
export { MAX_SIGNATURE_AGE_MS } from './server/handler.js';
// High-level SDK integration entrypoint
export { createFilesNamespace } from './integration/files-namespace.js';
export type { FilesNamespace } from './integration/files-namespace.js';
// Integration helpers — wire handler + pending registry onto a channel
export { attachFileHandler } from './integration/wire-server.js';
export { attachClientRouting } from './integration/wire-client.js';
// Path utilities
export {
posixNormalize,
posixJoin,
posixDirname,
posixBasename,
decodePercentEscapes,
isPathInside,
} from './utils/path.js';

View File

@@ -0,0 +1,142 @@
/**
* High-level `FilesNamespace` — the entry point that the SDK exposes via
* `Shade.files`. Memoizes the underlying `ShadeFileRpcChannel` and bridges
* so a single Shade can simultaneously serve files AND consume them from
* peers without paying the setup cost twice.
*/
import type { Shade } from '@shade/sdk';
import {
attachClientRouting,
attachFileHandler,
createClientStreamsBridge,
createFileClient,
createFileHandler,
createServerStreamsBridge,
PendingRpcRegistry,
ShadeFileRpcChannel,
type ClientStreamsBridge,
type CreateFileClientOptions,
type FileClient,
type FileHandler,
type FileHandlerConfig,
type ServerStreamsBridge,
} from '../index.js';
import { IdempotencyCache } from '../server/idempotency-cache.js';
export interface FilesNamespace {
/**
* Register a file handler. Throws if a handler is already attached on
* this Shade — only one server per Shade. The returned function detaches
* the handler and tears down its idempotency / retention timers.
*/
serve(handler: FileHandlerConfig): Promise<() => Promise<void>>;
/**
* Build a typed file client for `peer`. Multiple concurrent clients to
* different peers share the same channel + streams bridge.
*/
client(peer: string, opts?: Omit<CreateFileClientOptions, 'streamsBridge'>): Promise<FileClient>;
/** Tear down channel + bridges. After destroy(), serve()/client() throw. */
destroy(): Promise<void>;
}
interface NamespaceState {
channel: ShadeFileRpcChannel;
pending: PendingRpcRegistry;
serverBridge: ServerStreamsBridge | null;
clientBridge: ClientStreamsBridge | null;
serverHandler: FileHandler | null;
serverDetach: (() => void) | null;
clientDetach: (() => void) | null;
destroyed: boolean;
}
/**
* Construct a `FilesNamespace` bound to a Shade instance. The SDK's
* `Shade.files` getter calls this lazily and memoizes the result.
*/
export function createFilesNamespace(shade: Shade): FilesNamespace {
const state: NamespaceState = {
channel: new ShadeFileRpcChannel(shade),
pending: new PendingRpcRegistry(),
serverBridge: null,
clientBridge: null,
serverHandler: null,
serverDetach: null,
clientDetach: null,
destroyed: false,
};
function ensureAlive(): void {
if (state.destroyed) throw new Error('FilesNamespace: destroyed');
}
return {
async serve(handlerConfig) {
ensureAlive();
if (state.serverHandler !== null) {
throw new Error('FilesNamespace: a handler is already registered (one per Shade)');
}
// Lazy server-side streams bridge.
if (state.serverBridge === null) {
state.serverBridge = await createServerStreamsBridge(shade);
}
const handler = createFileHandler(shade, {
...handlerConfig,
streamsBridge: state.serverBridge,
});
const detach = attachFileHandler(state.channel, handler);
state.serverHandler = handler;
state.serverDetach = detach;
// Wire BackgroundHooks.onPruneFiles to the new handler's idempotency
// cache. Use the symbol-exposed internals (works because FileHandler
// attaches them via Object.assign).
const internals = (handler as unknown as { [k: symbol]: { idempotency: IdempotencyCache } })[
Symbol.for('@shade/files/internal')
];
const background = (shade as unknown as { background?: { setHook?: (n: string, f: () => void) => void } }).background;
if (background?.setHook !== undefined && internals !== undefined) {
background.setHook('onPruneFiles', () => {
internals.idempotency.prune();
});
}
return async () => {
if (state.serverDetach !== null) state.serverDetach();
state.serverHandler = null;
state.serverDetach = null;
if (background?.setHook !== undefined) {
background.setHook('onPruneFiles', undefined as unknown as () => void);
}
};
},
async client(peer, opts = {}) {
ensureAlive();
// Lazy client-side streams bridge.
if (state.clientBridge === null) {
state.clientBridge = await createClientStreamsBridge(shade);
}
// Attach response routing once.
if (state.clientDetach === null) {
state.clientDetach = attachClientRouting(state.channel, state.pending);
}
return createFileClient(shade, state.channel, state.pending, peer, {
...opts,
streamsBridge: state.clientBridge,
});
},
async destroy() {
if (state.destroyed) return;
state.destroyed = true;
if (state.serverDetach !== null) state.serverDetach();
if (state.clientDetach !== null) state.clientDetach();
if (state.serverHandler !== null) state.serverHandler.destroy();
if (state.serverBridge !== null) await state.serverBridge.destroy();
if (state.clientBridge !== null) await state.clientBridge.destroy();
state.channel.destroy();
state.pending.rejectAll(new Error('FilesNamespace destroyed'));
},
};
}

View File

@@ -0,0 +1,23 @@
import { ShadeFileRpcChannel } from '../rpc/channel.js';
import { PendingRpcRegistry } from '../rpc/pending.js';
/**
* Wire a `PendingRpcRegistry` onto a channel so incoming `response` and
* `error` envelopes resolve the matching client-side promises.
*
* The same channel can serve multiple clients (one per peer); the
* registry is shared because correlation IDs are globally unique.
*/
export function attachClientRouting(
channel: ShadeFileRpcChannel,
pending: PendingRpcRegistry,
): () => void {
const previous = channel.setHooks({
onResponse: (_from, env) => pending.resolveResponse(env.envelope),
onError: (_from, env) => pending.resolveError(env.envelope),
});
return () => {
pending.rejectAll(new Error('client routing detached'));
channel.setHooks(previous);
};
}

View File

@@ -0,0 +1,35 @@
import { ShadeFileRpcChannel } from '../rpc/channel.js';
import type { FileHandler } from '../server/handler.js';
/**
* Connect a `FileHandler` to a `ShadeFileRpcChannel`. Returns an unsubscribe
* function that detaches the handler. Throws if a handler is already wired
* on this channel.
*
* The wiring:
* - On `request` envelopes: invoke `handler.handleRequest`, then send the
* returned response/error envelope back to the requester.
* - On `cancel` envelopes: forward to `handler.handleCancel`.
*/
export function attachFileHandler(
channel: ShadeFileRpcChannel,
handler: FileHandler,
): () => void {
const previous = channel.setHooks({
onRequest: async (from, env) => {
const response = await handler.handleRequest(from, env.envelope);
try {
await channel.send(from, response);
} catch (err) {
console.error('[shade-files] failed to send response:', err);
}
},
onCancel: (from, env) => {
handler.handleCancel(from, env.envelope);
},
});
return () => {
handler.destroy();
channel.setHooks(previous);
};
}

View File

@@ -0,0 +1,72 @@
import { sha256 } from '@noble/hashes/sha2.js';
/**
* Canonical bytes for an `@shade/files` RPC request signature.
*
* "rpc\0addr=<sender>\0at=<signedAt>\0kind=<kind>\0id=<id>\0argsHash=<hex>\0"
*
* Mirror of `canonicalControlBytes` in `@shade/sdk/src/streams-bridge.ts:188`.
* The signature binds the (sender, op kind, request id, args, signedAt)
* tuple — defense-in-depth on top of Double Ratchet authentication.
*/
export function canonicalRpcBytes(args: {
address: string;
signedAt: number;
kind: string;
id: string;
argsHash: Uint8Array;
}): Uint8Array {
const enc = new TextEncoder();
const fields = [
`rpc\0`,
`addr=${args.address}\0`,
`at=${args.signedAt}\0`,
`kind=${args.kind}\0`,
`id=${args.id}\0`,
`argsHash=${bytesToHex(args.argsHash)}\0`,
];
return enc.encode(fields.join(''));
}
/**
* Stable canonical-JSON serialization for hashing args. Sorts object keys
* recursively so two equivalent inputs hash to identical bytes regardless
* of property order.
*/
export function canonicalJsonStringify(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) {
return `[${value.map((v) => canonicalJsonStringify(v)).join(',')}]`;
}
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
const parts: string[] = [];
for (const key of keys) {
if (obj[key] === undefined) continue;
parts.push(`${JSON.stringify(key)}:${canonicalJsonStringify(obj[key])}`);
}
return `{${parts.join(',')}}`;
}
/** Hash args via canonical-JSON → SHA-256 (32 bytes). */
export function hashArgs(args: unknown): Uint8Array {
const enc = new TextEncoder();
return sha256(enc.encode(canonicalJsonStringify(args)));
}
export function bytesToHex(b: Uint8Array): string {
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
}
export function bytesToBase64(b: Uint8Array): string {
let bin = '';
for (let i = 0; i < b.length; i++) bin += String.fromCharCode(b[i]!);
return btoa(bin);
}
export function base64ToBytes(s: string): Uint8Array {
const bin = atob(s);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}

View File

@@ -0,0 +1,32 @@
/**
* Generate a correlation ID — 16 random bytes, base64url-encoded (22 chars
* after stripping padding). Same shape as `RequestIdSchema`.
*
* Uses `globalThis.crypto.getRandomValues` so it works in Bun, Node ≥19,
* and browsers without any platform-specific imports.
*/
export function generateRequestId(): string {
const buf = new Uint8Array(16);
globalThis.crypto.getRandomValues(buf);
return base64UrlEncode(buf);
}
/** Same as `generateRequestId` — distinct name for callsite clarity. */
export function generateIdempotencyKey(): string {
return generateRequestId();
}
export function base64UrlEncode(bytes: Uint8Array): string {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function base64UrlDecode(s: string): Uint8Array {
const padded = s.replace(/-/g, '+').replace(/_/g, '/');
const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
const bin = atob(padded + pad);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}

View File

@@ -0,0 +1,70 @@
import { z } from 'zod';
import {
RpcCancelSchema,
RpcErrorSchema,
RpcRequestSchema,
RpcResponseSchema,
type RpcCancel,
type RpcEnvelope,
type RpcError,
type RpcRequest,
type RpcResponse,
} from '../schemas/envelope.js';
import { KIND_PREFIX } from './version.js';
/** Tagged classification of any incoming envelope. */
export type ClassifiedEnvelope =
| { kind: 'request'; envelope: RpcRequest }
| { kind: 'response'; envelope: RpcResponse }
| { kind: 'error'; envelope: RpcError }
| { kind: 'cancel'; envelope: RpcCancel };
const RpcAnySchema = z.union([
RpcRequestSchema,
RpcResponseSchema,
RpcErrorSchema,
RpcCancelSchema,
]);
/** Encode an envelope to JSON plaintext for `Shade.send`. */
export function encodeEnvelope(env: RpcEnvelope): string {
return JSON.stringify(env);
}
/**
* Quick-detection: does this plaintext look like an `@shade/files` envelope?
* Used by `ShadeFileRpcChannel` to skip non-files messages cheaply.
*/
export function looksLikeFileEnvelope(plaintext: string): boolean {
return plaintext.includes(KIND_PREFIX);
}
/**
* Parse and classify an incoming plaintext. Returns null on any malformed
* input — the channel ignores those silently (could be a different
* protocol on the same Shade.send pipe).
*/
export function tryParseEnvelope(plaintext: string): ClassifiedEnvelope | null {
let raw: unknown;
try {
raw = JSON.parse(plaintext);
} catch {
return null;
}
const result = RpcAnySchema.safeParse(raw);
if (!result.success) return null;
return classify(result.data);
}
export function classify(env: RpcEnvelope): ClassifiedEnvelope {
if (env.kind === 'shade.fs.cancel/v1') {
return { kind: 'cancel', envelope: env as RpcCancel };
}
if (env.kind === 'shade.fs.error/v1') {
return { kind: 'error', envelope: env as RpcError };
}
if (env.kind.endsWith('.response')) {
return { kind: 'response', envelope: env as RpcResponse };
}
return { kind: 'request', envelope: env as RpcRequest };
}

View File

@@ -0,0 +1,53 @@
/** Canonical kind names for v1 ops. */
export const KIND_LIST_V1 = 'shade.fs.list/v1' as const;
export const KIND_STAT_V1 = 'shade.fs.stat/v1' as const;
export const KIND_MKDIR_V1 = 'shade.fs.mkdir/v1' as const;
export const KIND_DELETE_V1 = 'shade.fs.delete/v1' as const;
export const KIND_MOVE_V1 = 'shade.fs.move/v1' as const;
export const KIND_READ_V1 = 'shade.fs.read/v1' as const;
export const KIND_WRITE_V1 = 'shade.fs.write/v1' as const;
export const KIND_GET_THUMBNAIL_V1 = 'shade.fs.getThumbnail/v1' as const;
export const KIND_CUSTOM_V1 = 'shade.fs.custom/v1' as const;
export const KIND_ERROR_V1 = 'shade.fs.error/v1' as const;
export const KIND_CANCEL_V1 = 'shade.fs.cancel/v1' as const;
/** Compute the response kind from a request kind: `'shade.fs.foo/v1'` → `'shade.fs.foo/v1.response'`. */
export function responseKindOf<K extends string>(kind: K): `${K}.response` {
return `${kind}.response`;
}
/** Standard op identifier — used in `OpContext.op`. */
export type StandardOp =
| 'list'
| 'stat'
| 'mkdir'
| 'delete'
| 'move'
| 'read'
| 'write'
| 'getThumbnail';
const KIND_TO_OP: Record<string, StandardOp> = {
[KIND_LIST_V1]: 'list',
[KIND_STAT_V1]: 'stat',
[KIND_MKDIR_V1]: 'mkdir',
[KIND_DELETE_V1]: 'delete',
[KIND_MOVE_V1]: 'move',
[KIND_READ_V1]: 'read',
[KIND_WRITE_V1]: 'write',
[KIND_GET_THUMBNAIL_V1]: 'getThumbnail',
};
export function opOfKind(kind: string): StandardOp | null {
return KIND_TO_OP[kind] ?? null;
}
/** Mutation ops require an idempotency key on retries. */
export const MUTATION_OPS = new Set<StandardOp | 'custom'>([
'mkdir',
'delete',
'move',
'write',
'custom',
]);

View File

@@ -0,0 +1,24 @@
/** Wire-protocol version. Bump when introducing breaking changes. */
export const SHADE_FILES_VERSION = '0.2.0';
/** Substring every `@shade/files` plaintext starts with — used by the channel filter. */
export const KIND_PREFIX = 'shade.fs';
/** Currently-supported op kinds. Extend when introducing new versions. */
export const SUPPORTED_KINDS = [
'shade.fs.list/v1',
'shade.fs.stat/v1',
'shade.fs.mkdir/v1',
'shade.fs.delete/v1',
'shade.fs.move/v1',
'shade.fs.read/v1',
'shade.fs.write/v1',
'shade.fs.getThumbnail/v1',
'shade.fs.custom/v1',
] as const;
export type SupportedKind = (typeof SUPPORTED_KINDS)[number];
const SUPPORTED_KIND_SET = new Set<string>(SUPPORTED_KINDS);
export function isSupportedKind(kind: string): kind is SupportedKind {
return SUPPORTED_KIND_SET.has(kind);
}

View File

@@ -0,0 +1,35 @@
import React, { createContext, useContext, useMemo } from 'react';
import type { Shade } from '@shade/sdk';
import type { FilesNamespace } from '../integration/files-namespace.js';
export interface ShadeFilesContextValue {
shade: Shade;
files: FilesNamespace;
}
const ShadeFilesContext = createContext<ShadeFilesContextValue | null>(null);
export interface ShadeFilesProviderProps {
/** Initialized `Shade` instance. `files` namespace is read off it lazily. */
shade: Shade;
children: React.ReactNode;
}
/**
* Provider for `@shade/files` React hooks. Distinct from
* `<ShadeRuntimeProvider>` in `@shade/widgets` so file-RPC consumers
* don't pull in the widget tree.
*/
export function ShadeFilesProvider({ shade, children }: ShadeFilesProviderProps): React.ReactElement {
const value = useMemo<ShadeFilesContextValue>(() => ({ shade, files: shade.files }), [shade]);
return React.createElement(ShadeFilesContext.Provider, { value }, children);
}
/** Throws if no `<ShadeFilesProvider>` is mounted above. */
export function useShadeFilesContext(): ShadeFilesContextValue {
const ctx = useContext(ShadeFilesContext);
if (ctx === null) {
throw new Error('useShadeFilesContext: missing <ShadeFilesProvider>');
}
return ctx;
}

View File

@@ -0,0 +1,26 @@
/**
* React entry point for `@shade/files`.
*
* Note: hooks/components are tree-shakable. Importing from
* `@shade/files/react` (this entry) avoids pulling React into Bun-only
* server consumers that import from the root `@shade/files`.
*/
export { ShadeFilesProvider, useShadeFilesContext } from './ShadeFilesProvider.js';
export type { ShadeFilesContextValue, ShadeFilesProviderProps } from './ShadeFilesProvider.js';
export { useShadeFiles } from './useShadeFiles.js';
export type { UseShadeFilesOptions, UseShadeFilesResult } from './useShadeFiles.js';
export { useFileList } from './useFileList.js';
export type { UseFileListOptions, UseFileListResult } from './useFileList.js';
export {
useFileTransfer,
useFileUpload,
useFileDownload,
} from './useFileTransfer.js';
export type {
BulkTransferStatus,
UseFileTransferResult,
} from './useFileTransfer.js';

View File

@@ -0,0 +1,90 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { FileEntry } from '../schemas/file-entry.js';
import { useShadeFiles, type UseShadeFilesOptions } from './useShadeFiles.js';
export interface UseFileListResult {
entries: FileEntry[];
isLoading: boolean;
error: unknown;
hasMore: boolean;
/** Reset and reload the first page. */
refresh(): void;
/** Append the next page (no-op when `hasMore === false`). */
loadMore(): Promise<void>;
}
export interface UseFileListOptions extends UseShadeFilesOptions {
pageSize?: number;
}
/**
* Paginated list of `path` on `peer`. Fetches the first page on mount,
* exposes `loadMore()` for subsequent pages.
*/
export function useFileList(
peer: string,
path: string,
opts: UseFileListOptions = {},
): UseFileListResult {
const filesOpts: UseShadeFilesOptions = {};
if (opts.probeIntervalMs !== undefined) filesOpts.probeIntervalMs = opts.probeIntervalMs;
const { fs } = useShadeFiles(peer, filesOpts);
const pageSize = opts.pageSize ?? 100;
const [entries, setEntries] = useState<FileEntry[]>([]);
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [hasMore, setHasMore] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<unknown>(null);
const [tick, setTick] = useState(0);
const inflightRef = useRef<AbortController | null>(null);
// Initial load + reload on path/peer/refresh.
useEffect(() => {
if (fs === null) return;
inflightRef.current?.abort();
const ctrl = new AbortController();
inflightRef.current = ctrl;
setIsLoading(true);
setError(null);
fs.list(path, { pageSize, signal: ctrl.signal })
.then((page) => {
if (ctrl.signal.aborted) return;
setEntries(page.entries);
setHasMore(page.hasMore);
setCursor(page.nextCursor);
})
.catch((err) => {
if (!ctrl.signal.aborted) setError(err);
})
.finally(() => {
if (!ctrl.signal.aborted) setIsLoading(false);
});
return () => {
ctrl.abort();
};
}, [fs, path, pageSize, tick]);
const loadMore = useCallback(async (): Promise<void> => {
if (fs === null || !hasMore || cursor === undefined) return;
setIsLoading(true);
try {
const page = await fs.list(path, { pageSize, cursor });
setEntries((prev) => [...prev, ...page.entries]);
setHasMore(page.hasMore);
setCursor(page.nextCursor);
} catch (err) {
setError(err);
} finally {
setIsLoading(false);
}
}, [fs, path, pageSize, cursor, hasMore]);
return {
entries,
isLoading,
error,
hasMore,
refresh: () => setTick((t) => t + 1),
loadMore,
};
}

View File

@@ -0,0 +1,103 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { BulkTransferEvent, BulkTransferHandle, BulkTransferResult } from '../client/directory-types.js';
export interface BulkTransferStatus {
state: 'idle' | 'running' | 'done' | 'error' | 'aborted';
filesDone: number;
filesTotal: number;
bytesDone: number;
bytesTotal: number;
error: unknown;
result: BulkTransferResult | null;
/** Last event emitted by the underlying handle. */
lastEvent: BulkTransferEvent | null;
}
export interface UseFileTransferResult extends BulkTransferStatus {
start(handle: BulkTransferHandle): void;
abort(reason?: string): Promise<void>;
}
const INITIAL: BulkTransferStatus = {
state: 'idle',
filesDone: 0,
filesTotal: 0,
bytesDone: 0,
bytesTotal: 0,
error: null,
result: null,
lastEvent: null,
};
/**
* Generic React-state wrapper around a `BulkTransferHandle`. Apps wire the
* handle returned from `uploadDirectory()` / `downloadDirectory()` here to
* surface progress in their UI.
*
* `useFileUpload` and `useFileDownload` are thin presets — they call
* `start(...)` with the appropriate handle automatically.
*/
export function useFileTransfer(): UseFileTransferResult {
const [status, setStatus] = useState<BulkTransferStatus>(INITIAL);
const handleRef = useRef<BulkTransferHandle | null>(null);
const start = useCallback((handle: BulkTransferHandle): void => {
handleRef.current = handle;
setStatus({ ...INITIAL, state: 'running' });
void (async () => {
try {
for await (const ev of handle.events) {
setStatus((prev) => {
const next: BulkTransferStatus = { ...prev, lastEvent: ev };
if (ev.type === 'plan') {
next.filesTotal = ev.totalFiles;
next.bytesTotal = ev.totalBytes ?? 0;
} else if (ev.type === 'progress') {
next.filesDone = ev.filesDone;
next.bytesDone = ev.bytesDone;
next.bytesTotal = ev.bytesTotal ?? prev.bytesTotal;
next.filesTotal = ev.filesTotal;
} else if (ev.type === 'complete') {
next.state = 'done';
next.filesDone = ev.filesDone;
next.bytesDone = ev.bytesDone;
} else if (ev.type === 'abort') {
next.state = 'aborted';
next.error = new Error(ev.reason);
} else if (ev.type === 'file-error') {
next.error = ev.error;
}
return next;
});
}
const result = await handle.done();
setStatus((prev) => ({ ...prev, state: 'done', result }));
} catch (err) {
setStatus((prev) => ({ ...prev, state: 'error', error: err }));
}
})();
}, []);
const abort = useCallback(async (reason?: string): Promise<void> => {
if (handleRef.current === null) return;
await handleRef.current.abort(reason);
}, []);
// Cleanup on unmount: abort any in-flight transfer.
useEffect(() => {
return () => {
if (handleRef.current !== null) {
void handleRef.current.abort('unmount').catch(() => undefined);
}
};
}, []);
return { ...status, start, abort };
}
/**
* Preset: `useFileUpload` — semantically identical to `useFileTransfer`,
* named distinctly to mirror the `useFileDownload` pair.
*/
export const useFileUpload = useFileTransfer;
export const useFileDownload = useFileTransfer;

View File

@@ -0,0 +1,88 @@
import { useEffect, useRef, useState } from 'react';
import type { FileClient } from '../client/client.js';
import { useShadeFilesContext } from './ShadeFilesProvider.js';
export interface UseShadeFilesResult {
fs: FileClient | null;
isLoading: boolean;
error: unknown;
/** True after a successful `stat('/')` probe; false until proven online. */
isOnline: boolean;
/** Force a re-probe. */
refresh(): void;
}
export interface UseShadeFilesOptions {
/** Probe interval (ms) for `isOnline`. Default 30_000. Disable with 0. */
probeIntervalMs?: number;
}
/**
* Get a typed `FileClient` for `peer`. Memoizes per (shade, peer) so
* multiple components sharing the same peer reuse the same client.
*/
export function useShadeFiles(
peer: string,
opts: UseShadeFilesOptions = {},
): UseShadeFilesResult {
const { files } = useShadeFilesContext();
const [fs, setFs] = useState<FileClient | null>(null);
const [error, setError] = useState<unknown>(null);
const [isOnline, setIsOnline] = useState(false);
const [probeTick, setProbeTick] = useState(0);
const cancelledRef = useRef(false);
useEffect(() => {
cancelledRef.current = false;
let mounted = true;
setError(null);
setFs(null);
files
.client(peer)
.then((client) => {
if (!mounted) {
client.close();
return;
}
setFs(client);
})
.catch((err) => {
if (mounted) setError(err);
});
return () => {
mounted = false;
cancelledRef.current = true;
};
}, [files, peer]);
// Periodic probe: stat('/') as a cheap reachability check.
useEffect(() => {
if (fs === null) return undefined;
const intervalMs = opts.probeIntervalMs ?? 30_000;
let cancelled = false;
const probe = (): void => {
fs.stat('/')
.then(() => {
if (!cancelled) setIsOnline(true);
})
.catch(() => {
if (!cancelled) setIsOnline(false);
});
};
probe();
if (intervalMs <= 0) return () => undefined;
const timer = setInterval(probe, intervalMs);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [fs, opts.probeIntervalMs, probeTick]);
return {
fs,
isLoading: fs === null && error === null,
error,
isOnline,
refresh: () => setProbeTick((t) => t + 1),
};
}

View File

@@ -0,0 +1,107 @@
import type { Shade } from '@shade/sdk';
import {
encodeEnvelope,
looksLikeFileEnvelope,
tryParseEnvelope,
type ClassifiedEnvelope,
} from '../protocol/envelope-codec.js';
import type { RpcEnvelope } from '../schemas/envelope.js';
/**
* Routing hooks called by the channel when it receives different envelope
* classes. M-Files-1 ships the skeleton; M-Files-2 wires up real handlers
* (server-side dispatcher, client-side response routing).
*/
export interface RpcChannelHooks {
onRequest?: (from: string, env: ClassifiedEnvelope & { kind: 'request' }) => Promise<void>;
onResponse?: (from: string, env: ClassifiedEnvelope & { kind: 'response' }) => void;
onError?: (from: string, env: ClassifiedEnvelope & { kind: 'error' }) => void;
onCancel?: (from: string, env: ClassifiedEnvelope & { kind: 'cancel' }) => void;
}
/**
* `ShadeFileRpcChannel` rides on top of `Shade.send`/`Shade.onMessage`
* and routes `shade.fs.*` JSON envelopes to registered hooks.
*
* Counterpart to `ShadeControlChannel` from `@shade/sdk` — but with
* request-response semantics instead of fire-and-forget.
*
* One channel per `Shade` instance; the SDK's `files` getter memoizes it.
* Both server and client roles share the same channel: the same Shade can
* both serve files and consume them from peers.
*/
export class ShadeFileRpcChannel {
private hooks: RpcChannelHooks = {};
private readonly unsubscribe: () => void;
private destroyed = false;
constructor(private readonly shade: Shade) {
this.unsubscribe = shade.onMessage(async (from, plaintext) => {
if (!looksLikeFileEnvelope(plaintext)) return;
const classified = tryParseEnvelope(plaintext);
if (classified === null) return;
await this.dispatch(from, classified);
});
}
/**
* Merge hooks into the current set. Returns the previous values for the
* keys being set so callers can restore them on cleanup. Other hook
* slots are left unchanged — the same channel can simultaneously serve
* (`onRequest`+`onCancel`) and consume (`onResponse`+`onError`).
*/
setHooks(hooks: RpcChannelHooks): RpcChannelHooks {
if (this.destroyed) throw new Error('ShadeFileRpcChannel: destroyed');
const prev: RpcChannelHooks = {};
const keys = ['onRequest', 'onResponse', 'onError', 'onCancel'] as const;
for (const key of keys) {
if (key in hooks) {
prev[key] = this.hooks[key] as never;
this.hooks[key] = hooks[key] as never;
}
}
return prev;
}
/**
* Send an envelope to a peer. Encrypts via `Shade.send` (Double Ratchet)
* then delivers the ratchet envelope to the peer's `transferRoute()`
* control endpoint via the SDK's configured envelope transport.
*/
async send(peerAddress: string, envelope: RpcEnvelope): Promise<void> {
if (this.destroyed) throw new Error('ShadeFileRpcChannel: destroyed');
const plaintext = encodeEnvelope(envelope);
const ratchetEnvelope = await this.shade.send(peerAddress, plaintext);
await this.shade.deliverControlEnvelope(peerAddress, ratchetEnvelope);
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
this.unsubscribe();
this.hooks = {};
}
private async dispatch(from: string, classified: ClassifiedEnvelope): Promise<void> {
try {
switch (classified.kind) {
case 'request':
if (this.hooks.onRequest !== undefined) {
await this.hooks.onRequest(from, classified);
}
return;
case 'response':
this.hooks.onResponse?.(from, classified);
return;
case 'error':
this.hooks.onError?.(from, classified);
return;
case 'cancel':
this.hooks.onCancel?.(from, classified);
return;
}
} catch (err) {
console.error('[ShadeFileRpcChannel] dispatch error:', err);
}
}
}

View File

@@ -0,0 +1,113 @@
import { CancelledError, OperationTimeoutError, fileErrorFromPayload } from '../schemas/errors.js';
import type { RpcError, RpcResponse } from '../schemas/envelope.js';
interface PendingEntry {
resolve: (result: unknown) => void;
reject: (err: unknown) => void;
timer: ReturnType<typeof setTimeout> | null;
abortListener: (() => void) | null;
signal?: AbortSignal | undefined;
}
export interface RegisterOptions {
timeoutMs?: number;
signal?: AbortSignal;
/** Called when client cancels (signal aborted or timeout); transport can ship an `RpcCancel`. */
onCancel?: (reason: 'timeout' | 'signal') => void;
}
/**
* Client-side tracker for in-flight RPC calls. Routes incoming
* `RpcResponse`/`RpcError` to the right pending promise via correlation id.
*/
export class PendingRpcRegistry {
private readonly entries = new Map<string, PendingEntry>();
/** Register a pending request. Returns a Promise resolved/rejected by routing. */
register<T = unknown>(requestId: string, opts: RegisterOptions = {}): Promise<T> {
const p = new Promise<T>((resolve, reject) => {
const cleanup = (): void => {
const e = this.entries.get(requestId);
if (e === undefined) return;
if (e.timer !== null) clearTimeout(e.timer);
if (e.abortListener !== null && e.signal !== undefined) {
e.signal.removeEventListener('abort', e.abortListener);
}
this.entries.delete(requestId);
};
const entry: PendingEntry = {
resolve: (v) => {
cleanup();
resolve(v as T);
},
reject: (err) => {
cleanup();
reject(err);
},
timer: null,
abortListener: null,
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
};
if (opts.timeoutMs !== undefined && opts.timeoutMs > 0) {
entry.timer = setTimeout(() => {
opts.onCancel?.('timeout');
entry.reject(new OperationTimeoutError());
}, opts.timeoutMs);
}
if (opts.signal !== undefined) {
if (opts.signal.aborted) {
// Defer microtask so caller's await sees the rejection.
queueMicrotask(() => {
opts.onCancel?.('signal');
entry.reject(new CancelledError());
});
} else {
const listener = (): void => {
opts.onCancel?.('signal');
entry.reject(new CancelledError());
};
entry.abortListener = listener;
opts.signal.addEventListener('abort', listener, { once: true });
}
}
this.entries.set(requestId, entry);
});
// Attach a no-op rejection handler so Bun's strict unhandled-rejection
// detection doesn't flag the registered promise BEFORE the caller's
// `await` attaches its handler. The caller's await still observes the
// rejection through its own handler chain.
p.catch(() => {
/* suppress unhandled-rejection warning */
});
return p;
}
/** Route an incoming response. */
resolveResponse(env: RpcResponse): void {
const entry = this.entries.get(env.id);
if (entry === undefined) return; // unknown id — possibly a stale duplicate
entry.resolve(env.result);
}
/** Route an incoming error envelope. */
resolveError(env: RpcError): void {
const entry = this.entries.get(env.id);
if (entry === undefined) return;
entry.reject(fileErrorFromPayload(env.error));
}
/** Reject all pending entries — for shutdown. */
rejectAll(reason: unknown): void {
for (const entry of this.entries.values()) entry.reject(reason);
this.entries.clear();
}
/** For tests / metrics. */
size(): number {
return this.entries.size;
}
}

View File

@@ -0,0 +1,46 @@
import { z } from 'zod';
import { FileErrorPayloadSchema } from './errors.js';
import { IdempotencyKeySchema, RequestIdSchema } from './primitives.js';
const KIND_REQUEST_PATTERN = /^shade\.fs\.[A-Za-z0-9.-]+\/v\d+$/;
const KIND_RESPONSE_PATTERN = /^shade\.fs\.[A-Za-z0-9.-]+\/v\d+\.response$/;
/**
* Request envelope. Carries op kind, correlation id, args (validated per
* op), idempotency key (mutations only), and an Ed25519 signature binding
* `(sender, kind, id, argsHash, signedAt)`.
*/
export const RpcRequestSchema = z.object({
kind: z.string().regex(KIND_REQUEST_PATTERN, 'invalid request kind'),
id: RequestIdSchema,
args: z.unknown(),
idempotencyKey: IdempotencyKeySchema.optional(),
attempt: z.number().int().positive().optional(),
deadlineMs: z.number().int().positive().optional(),
sig: z.string().min(1).max(128),
signedAt: z.number().int().positive(),
});
export type RpcRequest = z.infer<typeof RpcRequestSchema>;
export const RpcResponseSchema = z.object({
kind: z.string().regex(KIND_RESPONSE_PATTERN, 'invalid response kind'),
id: RequestIdSchema,
result: z.unknown(),
});
export type RpcResponse = z.infer<typeof RpcResponseSchema>;
export const RpcErrorSchema = z.object({
kind: z.literal('shade.fs.error/v1'),
id: RequestIdSchema,
error: FileErrorPayloadSchema,
});
export type RpcError = z.infer<typeof RpcErrorSchema>;
export const RpcCancelSchema = z.object({
kind: z.literal('shade.fs.cancel/v1'),
id: RequestIdSchema,
reason: z.string().max(512).optional(),
});
export type RpcCancel = z.infer<typeof RpcCancelSchema>;
export type RpcEnvelope = RpcRequest | RpcResponse | RpcError | RpcCancel;

View File

@@ -0,0 +1,228 @@
import { z } from 'zod';
import { ShadeError } from '@shade/core';
export const FileErrorCodeSchema = z.enum([
'NOT_FOUND',
'PERMISSION_DENIED',
'CONFLICT',
'QUOTA_EXCEEDED',
'RATE_LIMIT',
'PATH_VALIDATION',
'FINGERPRINT_REQUIRED',
'OPERATION_TIMEOUT',
'IDEMPOTENCY_CONFLICT',
'CANCELLED',
'INTERNAL',
'NOT_IMPLEMENTED',
'CUSTOM_OP_REJECTED',
'INVALID_SIGNATURE',
'INVALID_ARGS',
]);
export type FileErrorCode = z.infer<typeof FileErrorCodeSchema>;
export const FileErrorPayloadSchema = z.object({
code: FileErrorCodeSchema,
message: z.string().max(2048),
/** Suggested retry delay in ms; only set for retriable errors (rate limit, transient). */
retryAfterMs: z.number().int().nonnegative().optional(),
/** Path or arg field the error refers to. */
field: z.string().max(128).optional(),
/** Optional cause string (sanitized — no stack traces leak from server). */
cause: z.string().max(2048).optional(),
});
export type FileErrorPayload = z.infer<typeof FileErrorPayloadSchema>;
// ─── Class hierarchy ─────────────────────────────────────────
/**
* Base class for all `@shade/files` errors. Extends `ShadeError` so the
* existing `errorToHttpStatus()` mapping in `@shade/core` continues to apply.
*/
export class FileError extends ShadeError {
readonly payload: FileErrorPayload;
constructor(payload: FileErrorPayload) {
super(`SHADE_FS_${payload.code}`, payload.message);
this.name = 'FileError';
this.payload = payload;
}
override toJSON(): { name: string; code: string; message: string; payload: FileErrorPayload } {
return {
name: this.name,
code: this.code,
message: this.message,
payload: this.payload,
};
}
}
export class NotFoundError extends FileError {
constructor(message = 'Not found', field?: string) {
super({ code: 'NOT_FOUND', message, ...(field !== undefined ? { field } : {}) });
this.name = 'NotFoundError';
}
}
export class PermissionDeniedError extends FileError {
constructor(message = 'Permission denied') {
super({ code: 'PERMISSION_DENIED', message });
this.name = 'PermissionDeniedError';
}
}
export class ConflictError extends FileError {
constructor(message: string) {
super({ code: 'CONFLICT', message });
this.name = 'ConflictError';
}
}
export class QuotaExceededError extends FileError {
constructor(message = 'Quota exceeded', retryAfterMs?: number) {
super({
code: 'QUOTA_EXCEEDED',
message,
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
});
this.name = 'QuotaExceededError';
}
}
export class FsRateLimitError extends FileError {
constructor(message = 'Rate limit exceeded', retryAfterMs?: number) {
super({
code: 'RATE_LIMIT',
message,
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
});
this.name = 'FsRateLimitError';
}
}
export class PathValidationError extends FileError {
constructor(message: string, field = 'path') {
super({ code: 'PATH_VALIDATION', message, field });
this.name = 'PathValidationError';
}
}
export class FingerprintRequiredError extends FileError {
constructor(message = 'Peer fingerprint must be verified before this operation') {
super({ code: 'FINGERPRINT_REQUIRED', message });
this.name = 'FingerprintRequiredError';
}
}
export class OperationTimeoutError extends FileError {
constructor(message = 'Operation timed out') {
super({ code: 'OPERATION_TIMEOUT', message });
this.name = 'OperationTimeoutError';
}
}
export class IdempotencyConflictError extends FileError {
constructor(
message = 'Idempotency key reused with different arguments',
) {
super({ code: 'IDEMPOTENCY_CONFLICT', message });
this.name = 'IdempotencyConflictError';
}
}
export class CancelledError extends FileError {
constructor(message = 'Cancelled') {
super({ code: 'CANCELLED', message });
this.name = 'CancelledError';
}
}
export class InternalFileError extends FileError {
constructor(message = 'Internal server error', cause?: string) {
super({
code: 'INTERNAL',
message,
...(cause !== undefined ? { cause } : {}),
});
this.name = 'InternalFileError';
}
}
export class NotImplementedError extends FileError {
constructor(op: string) {
super({ code: 'NOT_IMPLEMENTED', message: `Operation not implemented: ${op}` });
this.name = 'NotImplementedError';
}
}
export class CustomOpRejectedError extends FileError {
constructor(message: string) {
super({ code: 'CUSTOM_OP_REJECTED', message });
this.name = 'CustomOpRejectedError';
}
}
export class InvalidSignatureError extends FileError {
constructor(message = 'RPC envelope signature verification failed') {
super({ code: 'INVALID_SIGNATURE', message });
this.name = 'InvalidSignatureError';
}
}
export class InvalidArgsError extends FileError {
constructor(message: string, field?: string) {
super({ code: 'INVALID_ARGS', message, ...(field !== undefined ? { field } : {}) });
this.name = 'InvalidArgsError';
}
}
/**
* Reconstruct the right `FileError` subclass from a wire payload. Used by
* the client to surface typed errors from server responses.
*/
export function fileErrorFromPayload(payload: FileErrorPayload): FileError {
switch (payload.code) {
case 'NOT_FOUND':
return new NotFoundError(payload.message, payload.field);
case 'PERMISSION_DENIED':
return new PermissionDeniedError(payload.message);
case 'CONFLICT':
return new ConflictError(payload.message);
case 'QUOTA_EXCEEDED':
return new QuotaExceededError(payload.message, payload.retryAfterMs);
case 'RATE_LIMIT':
return new FsRateLimitError(payload.message, payload.retryAfterMs);
case 'PATH_VALIDATION':
return new PathValidationError(payload.message, payload.field);
case 'FINGERPRINT_REQUIRED':
return new FingerprintRequiredError(payload.message);
case 'OPERATION_TIMEOUT':
return new OperationTimeoutError(payload.message);
case 'IDEMPOTENCY_CONFLICT':
return new IdempotencyConflictError(payload.message);
case 'CANCELLED':
return new CancelledError(payload.message);
case 'INTERNAL':
return new InternalFileError(payload.message, payload.cause);
case 'NOT_IMPLEMENTED':
return new NotImplementedError(payload.message);
case 'CUSTOM_OP_REJECTED':
return new CustomOpRejectedError(payload.message);
case 'INVALID_SIGNATURE':
return new InvalidSignatureError(payload.message);
case 'INVALID_ARGS':
return new InvalidArgsError(payload.message, payload.field);
}
}
/** Convert any thrown value into a `FileErrorPayload` for wire serialization. */
export function payloadFromError(err: unknown): FileErrorPayload {
if (err instanceof FileError) return err.payload;
if (err instanceof Error) {
return {
code: 'INTERNAL',
message: err.message.slice(0, 2048),
};
}
return { code: 'INTERNAL', message: 'unknown error' };
}

View File

@@ -0,0 +1,33 @@
import { z } from 'zod';
import { CursorSchema } from './primitives.js';
export const FileKindSchema = z.enum(['file', 'dir']);
export type FileKind = z.infer<typeof FileKindSchema>;
/**
* Directory entry shape. App-specific extension via `metadata` — keep it
* lean; large blobs (thumbnails) belong in `getThumbnail`, not here.
*/
export const FileEntrySchema = z.object({
/** Base name only — no path separators. */
name: z.string().min(1).max(512).refine((s) => !s.includes('/') && !s.includes('\\'), {
message: 'name must not contain path separators',
}),
kind: FileKindSchema,
/** Plaintext byte size; 0 for directories. */
size: z.number().int().nonnegative(),
/** Last-modified time, unix milliseconds. */
mtime: z.number().int(),
/** RFC 6838 media type if known. */
contentType: z.string().max(255).optional(),
/** App-specific extension fields. Default empty. */
metadata: z.record(z.string(), z.unknown()).default({}),
});
export type FileEntry = z.infer<typeof FileEntrySchema>;
export const ListPageSchema = z.object({
entries: z.array(FileEntrySchema),
nextCursor: CursorSchema.optional(),
hasMore: z.boolean(),
});
export type ListPage = z.infer<typeof ListPageSchema>;

View File

@@ -0,0 +1,5 @@
export * from './primitives.js';
export * from './file-entry.js';
export * from './ops.js';
export * from './envelope.js';
export * from './errors.js';

View File

@@ -0,0 +1,157 @@
import { z } from 'zod';
import { Base64Schema, CursorSchema, PathSchema, Sha256HexSchema } from './primitives.js';
import { FileEntrySchema, FileKindSchema, ListPageSchema } from './file-entry.js';
// ─── list ────────────────────────────────────────────────────
export const ListArgsSchema = z.object({
path: PathSchema,
cursor: CursorSchema.optional(),
pageSize: z.number().int().min(1).max(1000).default(100),
filter: z
.object({
prefix: z.string().max(256).optional(),
kind: FileKindSchema.optional(),
})
.optional(),
});
export type ListArgs = z.infer<typeof ListArgsSchema>;
export const ListResultSchema = ListPageSchema;
export type ListResult = z.infer<typeof ListResultSchema>;
// ─── stat ────────────────────────────────────────────────────
export const StatArgsSchema = z.object({ path: PathSchema });
export type StatArgs = z.infer<typeof StatArgsSchema>;
export const StatResultSchema = FileEntrySchema;
export type StatResult = z.infer<typeof StatResultSchema>;
// ─── mkdir ───────────────────────────────────────────────────
export const MkdirArgsSchema = z.object({
path: PathSchema,
recursive: z.boolean().default(false),
});
export type MkdirArgs = z.infer<typeof MkdirArgsSchema>;
export const MkdirResultSchema = z.object({ entry: FileEntrySchema });
export type MkdirResult = z.infer<typeof MkdirResultSchema>;
// ─── delete ──────────────────────────────────────────────────
export const DeleteArgsSchema = z.object({
path: PathSchema,
recursive: z.boolean().default(false),
});
export type DeleteArgs = z.infer<typeof DeleteArgsSchema>;
export const DeleteResultSchema = z.object({
deletedCount: z.number().int().nonnegative(),
});
export type DeleteResult = z.infer<typeof DeleteResultSchema>;
// ─── move ────────────────────────────────────────────────────
export const MoveArgsSchema = z.object({
src: PathSchema,
dst: PathSchema,
overwrite: z.boolean().default(false),
});
export type MoveArgs = z.infer<typeof MoveArgsSchema>;
export const MoveResultSchema = z.object({ entry: FileEntrySchema });
export type MoveResult = z.infer<typeof MoveResultSchema>;
// ─── read ────────────────────────────────────────────────────
export const ReadArgsSchema = z.object({
path: PathSchema,
range: z
.object({
start: z.number().int().nonnegative(),
end: z.number().int().positive(),
})
.optional(),
/** Force inline path even if size unknown / large. Use with care. */
preferInline: z.boolean().optional(),
});
export type ReadArgs = z.infer<typeof ReadArgsSchema>;
export const ReadResultSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('inline'),
bytesB64: Base64Schema,
size: z.number().int().nonnegative(),
sha256: Sha256HexSchema,
contentType: z.string().optional(),
}),
z.object({
kind: z.literal('streams'),
streamId: z.string(),
size: z.number().int().nonnegative(),
sha256: Sha256HexSchema,
contentType: z.string().optional(),
}),
]);
export type ReadResult = z.infer<typeof ReadResultSchema>;
// ─── write ───────────────────────────────────────────────────
export const WriteArgsSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('inline'),
path: PathSchema,
bytesB64: Base64Schema,
contentType: z.string().optional(),
overwrite: z.boolean().default(false),
}),
z.object({
kind: z.literal('streams'),
path: PathSchema,
/** Declared plaintext size; server clamps quota against this. */
size: z.number().int().nonnegative(),
contentType: z.string().optional(),
overwrite: z.boolean().default(false),
/** base64url(16) — matches `metadata.shadeFilesWriteId` on the streams transfer. */
writeId: z.string(),
}),
]);
export type WriteArgs = z.infer<typeof WriteArgsSchema>;
export const WriteResultSchema = z.object({ entry: FileEntrySchema });
export type WriteResult = z.infer<typeof WriteResultSchema>;
// ─── getThumbnail ────────────────────────────────────────────
export const ThumbnailSizeSchema = z.union([
z.literal(64),
z.literal(128),
z.literal(256),
z.literal(512),
]);
export type ThumbnailSize = z.infer<typeof ThumbnailSizeSchema>;
export const GetThumbnailArgsSchema = z.object({
path: PathSchema,
size: ThumbnailSizeSchema,
format: z.enum(['png', 'webp', 'jpeg']).default('png'),
});
export type GetThumbnailArgs = z.infer<typeof GetThumbnailArgsSchema>;
export const GetThumbnailResultSchema = z.object({
bytesB64: Base64Schema,
format: z.enum(['png', 'webp', 'jpeg']),
width: z.number().int().positive().max(4096),
height: z.number().int().positive().max(4096),
sha256: Sha256HexSchema,
});
export type GetThumbnailResult = z.infer<typeof GetThumbnailResultSchema>;
// ─── custom ──────────────────────────────────────────────────
export const CustomArgsSchema = z.object({
name: z.string().min(1).max(128).regex(/^[a-z0-9.-]+$/i, 'invalid custom op name'),
payload: z.unknown(),
});
export type CustomArgs = z.infer<typeof CustomArgsSchema>;
export const CustomResultSchema = z.object({
result: z.unknown(),
});
export type CustomResult = z.infer<typeof CustomResultSchema>;

View File

@@ -0,0 +1,42 @@
import { z } from 'zod';
/**
* POSIX-style absolute path. Must start with `/`, max 4096 chars, no NULs,
* no CR/LF/DEL, no backslashes (Windows-style rejected). Further policy
* (root-scope, traversal hardening) applied by `validatePath` server-side.
*/
export const PathSchema = z
.string()
.min(1)
.max(4096)
.regex(/^\//, 'must be absolute (start with /)')
.refine((s) => !/[\x00-\x08\x0a-\x1f\x7f\\]/.test(s), {
message: 'contains forbidden control or backslash characters',
});
export type Path = z.infer<typeof PathSchema>;
/** Base64url(16 bytes) — used for both requestId and idempotencyKey. */
export const RequestIdSchema = z
.string()
.min(22)
.max(22)
.regex(/^[A-Za-z0-9_-]+$/, 'invalid base64url');
export type RequestId = z.infer<typeof RequestIdSchema>;
export const IdempotencyKeySchema = RequestIdSchema;
export type IdempotencyKey = z.infer<typeof IdempotencyKeySchema>;
/**
* Opaque cursor token. Server-defined shape; clients pass through verbatim.
* Capped at 2 KiB to prevent over-sized cursors leaking server state.
*/
export const CursorSchema = z.string().min(1).max(2048);
export type Cursor = z.infer<typeof CursorSchema>;
/** Hex-encoded SHA-256 (64 chars). */
export const Sha256HexSchema = z.string().length(64).regex(/^[0-9a-f]+$/);
/** Standard base64 (with padding) used for binary blobs in JSON envelopes. */
export const Base64Schema = z.string().regex(/^[A-Za-z0-9+/]*=*$/);

View File

@@ -0,0 +1,73 @@
import { sha256 } from '@noble/hashes/sha2.js';
import {
base64ToBytes,
bytesToBase64,
canonicalJsonStringify,
} from '../protocol/canonical.js';
/**
* Opaque pagination cursor. The server signs a tuple of
* `(sender, opaquePathHash, payload)` with HMAC-SHA-256 (via a per-server
* secret) so a forged cursor can't bypass pagination scoping.
*
* The payload itself is server-defined; in tests we use simple `{ offset }`.
*/
export interface CursorBuilder {
encode(sender: string, pathHash: string, payload: unknown): string;
decode(sender: string, pathHash: string, cursor: string): unknown | null;
}
export function createCursorBuilder(serverSecret: Uint8Array): CursorBuilder {
if (serverSecret.length < 16) {
throw new Error('serverSecret must be at least 16 bytes');
}
function mac(input: Uint8Array): Uint8Array {
// Simple HMAC-SHA-256 implementation using @noble/hashes — keyed
// construction over secret || input. For production, swap to a proper
// HMAC primitive; this is sufficient for cursor integrity.
const padded = new Uint8Array(serverSecret.length + input.length);
padded.set(serverSecret, 0);
padded.set(input, serverSecret.length);
return sha256(padded);
}
return {
encode(sender, pathHash, payload) {
const json = canonicalJsonStringify({ s: sender, p: pathHash, d: payload });
const enc = new TextEncoder().encode(json);
const tag = mac(enc).slice(0, 16); // 128-bit truncation is plenty
const out = new Uint8Array(enc.length + tag.length);
out.set(enc, 0);
out.set(tag, enc.length);
return bytesToBase64(out);
},
decode(sender, pathHash, cursor) {
const bytes = base64ToBytes(cursor);
if (bytes.length < 17) return null;
const enc = bytes.slice(0, bytes.length - 16);
const tag = bytes.slice(bytes.length - 16);
const expected = mac(enc).slice(0, 16);
if (!constantTimeEqualBytes(tag, expected)) return null;
try {
const parsed = JSON.parse(new TextDecoder().decode(enc)) as {
s: string;
p: string;
d: unknown;
};
if (parsed.s !== sender || parsed.p !== pathHash) return null;
return parsed.d;
} catch {
return null;
}
},
};
}
function constantTimeEqualBytes(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;
return diff === 0;
}

View File

@@ -0,0 +1,86 @@
/**
* Custom op registry — typed via TypeScript declaration merging.
*
* Consumers register a custom op by:
* 1. Adding to `CustomOpsMap` via declaration merging (compile-time typing).
* 2. Passing a Zod-backed handler in `FileHandlerConfig.custom`.
*
* Server validates wire args against the registered Zod schema before
* invoking the user handler, then validates the user's response against
* the schema before shipping it.
*
* @example
* ```ts
* // In your app, once globally:
* declare module '@shade/files' {
* interface CustomOpsMap {
* 'app.deploy-mod': CustomOpDef<{ jarPath: string }, { deploymentId: string }>;
* }
* }
*
* // Server registers a handler:
* shade.files.serve({
* custom: {
* 'app.deploy-mod': {
* args: z.object({ jarPath: z.string() }),
* response: z.object({ deploymentId: z.string() }),
* handler: async (args, ctx) => ({ deploymentId: '...' }),
* },
* },
* });
*
* // Client gets typed I/O:
* const result = await fs.custom('app.deploy-mod', { jarPath: '...' });
* // ^? { deploymentId: string }
* ```
*/
import type { ZodType } from 'zod';
import type { OpContext } from './handler-context.js';
// ─── Public typing scaffold (declaration-merged by consumers) ─
/** Marker shape for one custom op entry — consumers extend `CustomOpsMap`. */
export interface CustomOpDef<A = unknown, R = unknown> {
args: A;
response: R;
}
/**
* Registry of all custom ops known at compile time. Empty by default —
* consumers extend via TypeScript declaration merging:
*
* ```ts
* declare module '@shade/files' {
* interface CustomOpsMap {
* 'app.foo': CustomOpDef<{ x: number }, { y: string }>;
* }
* }
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface CustomOpsMap {}
// ─── Server-side handler config ──────────────────────────────
/**
* Server-side definition for one custom op: the args schema (validated
* inbound), the response schema (validated outbound), and the handler.
*/
export interface CustomOpRegistration<TArgs = unknown, TResponse = unknown> {
args: ZodType<TArgs>;
response: ZodType<TResponse>;
handler: (args: TArgs, ctx: OpContext<{ name: string; payload: TArgs }>) => Promise<TResponse>;
/**
* Optional cost override for the rate limiter. Default: same as
* `opCost.custom` (1 unless overridden in `RateLimitConfig`).
*/
cost?: number;
}
/**
* Map from custom-op name to registration. The keys are the same names
* declared in `CustomOpsMap`. Required typing via declaration merging is
* not strict here — runtime validation is enforced by the registered Zod
* schemas, so consumers using untyped names still get safety.
*/
export type CustomOpRegistrations = Record<string, CustomOpRegistration<any, any>>;

View File

@@ -0,0 +1,59 @@
import type { Shade } from '@shade/sdk';
import type { StandardOp } from '../protocol/kinds.js';
export type OpKind = StandardOp | `custom:${string}`;
/**
* Per-call context handed to every server-side handler/middleware.
*
* Path-related ops set `path` to the (validated, normalized) primary path.
* For `move`, `path` is `args.src`. For ops without a path (`custom`), the
* field is the empty string.
*/
export interface OpContext<TArgs = unknown> {
/** The op being executed. */
op: OpKind;
/** Validated + normalized primary path; empty string for path-less ops. */
path: string;
/** Zod-validated args for this op. */
args: TArgs;
/** Peer address (from Shade.receive). */
sender: string;
/** Aborted on client cancel or timeout. */
signal: AbortSignal;
/** Idempotency key (mutations only). */
idempotencyKey: string | undefined;
/** Attempt counter (1-based). */
attemptNumber: number;
/** Wall-clock when dispatch started (for metrics). */
startedAt: number;
/** Lazy fingerprint accessor — call only if needed. */
fingerprint: () => Promise<string>;
/** Mutable scratchpad for middleware (e.g. cache fingerprint hit). */
metadata: Map<string, unknown>;
}
/** Build an `OpContext`. Internal — used by the dispatcher. */
export function buildOpContext<TArgs>(args: {
op: OpKind;
path: string;
parsedArgs: TArgs;
sender: string;
signal: AbortSignal;
idempotencyKey: string | undefined;
attemptNumber: number;
shade: Shade;
}): OpContext<TArgs> {
return {
op: args.op,
path: args.path,
args: args.parsedArgs,
sender: args.sender,
signal: args.signal,
idempotencyKey: args.idempotencyKey,
attemptNumber: args.attemptNumber,
startedAt: Date.now(),
fingerprint: () => args.shade.getFingerprintFor(args.sender),
metadata: new Map(),
};
}

View File

@@ -0,0 +1,701 @@
import type { Shade } from '@shade/sdk';
import type { ZodTypeAny } from 'zod';
import {
MUTATION_OPS,
opOfKind,
responseKindOf,
type StandardOp,
} from '../protocol/kinds.js';
import {
DeleteArgsSchema,
DeleteResultSchema,
GetThumbnailArgsSchema,
GetThumbnailResultSchema,
ListArgsSchema,
ListResultSchema,
MkdirArgsSchema,
MkdirResultSchema,
MoveArgsSchema,
MoveResultSchema,
ReadArgsSchema,
ReadResultSchema,
StatArgsSchema,
StatResultSchema,
WriteArgsSchema,
WriteResultSchema,
type DeleteArgs,
type DeleteResult,
type GetThumbnailArgs,
type ListArgs,
type ListResult,
type MkdirArgs,
type MkdirResult,
type MoveArgs,
type MoveResult,
type ReadArgs,
type StatArgs,
type StatResult,
type WriteArgs,
type WriteResult,
} from '../schemas/ops.js';
import {
CancelledError,
CustomOpRejectedError,
FileError,
FingerprintRequiredError,
InvalidArgsError,
InvalidSignatureError,
NotImplementedError,
PathValidationError,
payloadFromError,
} from '../schemas/errors.js';
import type { RpcCancel, RpcError, RpcRequest, RpcResponse } from '../schemas/envelope.js';
import { buildOpContext, type OpContext } from './handler-context.js';
import { IdempotencyCache, type IdempotencyCacheOptions } from './idempotency-cache.js';
import { RateLimiter, type RateLimitConfig } from './rate-limiter.js';
import { validatePath, type PathPolicy } from './path-policy.js';
import {
adaptReadResult,
adaptThumbnailResult,
adaptWriteArgs,
} from './io-adapters.js';
import type {
UserReadResult,
UserThumbnailResult,
UserWriteArgs,
} from './io-types.js';
import type { ServerStreamsBridge } from './streams-bridge.js';
import type { CustomOpRegistrations } from './custom-ops.js';
import {
METRIC_BYTES_IN,
METRIC_BYTES_OUT,
METRIC_FINGERPRINT_REJECT_TOTAL,
METRIC_IDEMPOTENCY_CONFLICT_TOTAL,
METRIC_IDEMPOTENCY_HIT_TOTAL,
METRIC_OP_DURATION_MS,
METRIC_OP_TOTAL,
METRIC_RATE_LIMIT_REJECT_TOTAL,
METRIC_SIGNATURE_REJECT_TOTAL,
NOOP_METRIC_SINK,
type MetricSink,
} from './metrics.js';
import {
CustomArgsSchema,
CustomResultSchema,
type CustomArgs,
} from '../schemas/ops.js';
import { canonicalRpcBytes, hashArgs } from '../protocol/canonical.js';
/** Replay window for the `signedAt` field on inbound RPC envelopes. */
export const MAX_SIGNATURE_AGE_MS = 5 * 60 * 1000;
// ─── Public types ────────────────────────────────────────────
export interface FileHandlerOps {
list?: (ctx: OpContext<ListArgs>) => Promise<ListResult>;
stat?: (ctx: OpContext<StatArgs>) => Promise<StatResult>;
mkdir?: (ctx: OpContext<MkdirArgs>) => Promise<MkdirResult>;
delete?: (ctx: OpContext<DeleteArgs>) => Promise<DeleteResult>;
move?: (ctx: OpContext<MoveArgs>) => Promise<MoveResult>;
/**
* User-supplied read handler. Returns either an `inline` payload (≤ 256
* KiB) or a `streams` payload with a precomputed sha256. The dispatcher
* adapts to the wire shape.
*/
read?: (ctx: OpContext<ReadArgs>) => Promise<UserReadResult>;
/**
* User-supplied write handler. Receives `UserWriteArgs` with a clean
* `Uint8Array` (inline) or `ReadableStream` + sha256-promise (streams)
* shape — the dispatcher hides the base64 / writeId wire details.
*/
write?: (ctx: OpContext<UserWriteArgs>) => Promise<WriteResult>;
/**
* User-supplied thumbnail handler. Bytes are validated for format magic
* before they're shipped to prevent format misclassification attacks.
*/
getThumbnail?: (ctx: OpContext<GetThumbnailArgs>) => Promise<UserThumbnailResult>;
}
export interface FileHandlerConfig extends FileHandlerOps {
pathPolicy?: PathPolicy;
rateLimits?: RateLimitConfig;
idempotency?: IdempotencyCacheOptions;
/**
* Required for read/write `streams` ops. Coordinates the inbound/outbound
* `@shade/transfer` transfers via `userMetadata.shadeFiles*Id` keys.
*/
streamsBridge?: ServerStreamsBridge;
/** Custom ops registry — see `CustomOpsMap` declaration-merging. */
custom?: CustomOpRegistrations;
/**
* Verify the Ed25519 signature on inbound RPC envelopes. Pluggable so
* apps can plug their own peer-identity store. Returning `false` rejects
* with `InvalidSignatureError`. Default: skip (Double Ratchet AEAD on
* the underlying envelope already authenticates the sender).
*
* The `signedAt` replay-window check (±5 min) is enforced regardless.
*/
verifySender?: (
sender: string,
canonicalBytes: Uint8Array,
sig: string,
) => boolean | Promise<boolean>;
/**
* Per-op fingerprint-verification gate. Return `'required'` to demand
* the peer's fingerprint has been verified out-of-band (via
* `isFingerprintVerified`); `'reject'` to deny outright;
* `'optional'` (default) to allow.
*/
requireFingerprintVerifiedFor?: (
ctx: OpContext<unknown>,
) => 'required' | 'optional' | 'reject' | Promise<'required' | 'optional' | 'reject'>;
/** Lookup whether the consumer has out-of-band verified the peer. */
isFingerprintVerified?: (sender: string) => boolean | Promise<boolean>;
/** Vendor-neutral metrics sink. */
onMetric?: MetricSink;
/** Called BEFORE the handler runs. Throw to deny. */
beforeOp?: (ctx: OpContext<unknown>) => void | Promise<void>;
/** Called AFTER the handler returns. Result is the validated response. */
afterOp?: (ctx: OpContext<unknown>, result: unknown) => void | Promise<void>;
/** Called when an op fails. Receives the error and the context. */
onError?: (err: unknown, ctx: OpContext<unknown>) => void;
/** Default per-op timeout in ms. Default 60_000. */
defaultTimeoutMs?: number;
/** Hard deadline for streams-bridge awaits / outbound transfers. Default 60_000. */
ioTimeoutMs?: number;
}
export interface FileHandler {
/** Handle an incoming request envelope. Returns the envelope to send back. */
handleRequest(from: string, request: RpcRequest): Promise<RpcResponse | RpcError>;
/** Handle an incoming cancel envelope. */
handleCancel(from: string, cancel: RpcCancel): void;
/** Free up internal state (timers, abort listeners). */
destroy(): void;
}
interface OpSchemaPair {
args: ZodTypeAny;
result: ZodTypeAny;
}
const OP_SCHEMAS: Record<StandardOp, OpSchemaPair> = {
list: { args: ListArgsSchema, result: ListResultSchema },
stat: { args: StatArgsSchema, result: StatResultSchema },
mkdir: { args: MkdirArgsSchema, result: MkdirResultSchema },
delete: { args: DeleteArgsSchema, result: DeleteResultSchema },
move: { args: MoveArgsSchema, result: MoveResultSchema },
read: { args: ReadArgsSchema, result: ReadResultSchema },
write: { args: WriteArgsSchema, result: WriteResultSchema },
getThumbnail: { args: GetThumbnailArgsSchema, result: GetThumbnailResultSchema },
};
// ─── createFileHandler ───────────────────────────────────────
/**
* Build a `FileHandler` for the server side. The returned object is
* registered with `ShadeFileRpcChannel.setHooks({ onRequest })` (typically
* via `Shade.files.serve(...)` in the SDK).
*/
export function createFileHandler(
shade: Shade,
config: FileHandlerConfig,
): FileHandler {
const idempotency = new IdempotencyCache(config.idempotency);
const rateLimiter = new RateLimiter(config.rateLimits);
const inflightCancellers = new Map<string, AbortController>();
const defaultTimeoutMs = config.defaultTimeoutMs ?? 60_000;
const ioTimeoutMs = config.ioTimeoutMs ?? 60_000;
const metrics: MetricSink = config.onMetric ?? NOOP_METRIC_SINK;
const customRegistrations = config.custom ?? {};
const isCustomKind = (kind: string): boolean => kind === 'shade.fs.custom/v1';
async function handleRequest(
from: string,
request: RpcRequest,
): Promise<RpcResponse | RpcError> {
// 0. Replay-window check (independent of sig — defends against
// intercept-and-resend even when sig verification is disabled).
const skewMs = Math.abs(Date.now() - request.signedAt);
if (skewMs > MAX_SIGNATURE_AGE_MS) {
metrics(METRIC_SIGNATURE_REJECT_TOTAL, 1, { reason: 'skew' });
return makeErrorEnvelope(
request,
new InvalidSignatureError(
`signedAt is ${skewMs}ms outside the ±${MAX_SIGNATURE_AGE_MS}ms replay window`,
),
);
}
// 0b. Optional Ed25519 sig verification — pluggable. The canonical
// bytes bind sender + kind + id + signedAt + sha256(args).
if (config.verifySender !== undefined) {
const argsHashBytes = hashArgs(request.args);
const canonical = canonicalRpcBytes({
address: from,
signedAt: request.signedAt,
kind: request.kind,
id: request.id,
argsHash: argsHashBytes,
});
let ok = false;
try {
ok = await config.verifySender(from, canonical, request.sig);
} catch (err) {
metrics(METRIC_SIGNATURE_REJECT_TOTAL, 1, { reason: 'throw' });
return makeErrorEnvelope(request, err);
}
if (!ok) {
metrics(METRIC_SIGNATURE_REJECT_TOTAL, 1, { reason: 'mismatch' });
return makeErrorEnvelope(request, new InvalidSignatureError());
}
}
// 1. Resolve op + handler. Custom ops route through `config.custom`.
let op: StandardOp | 'custom';
let argSchema: ZodTypeAny;
let resultSchema: ZodTypeAny;
let customHandler: CustomOpRegistrations[string] | undefined;
if (isCustomKind(request.kind)) {
op = 'custom';
argSchema = CustomArgsSchema;
resultSchema = CustomResultSchema;
} else {
const std = opOfKind(request.kind);
if (std === null) {
return makeErrorEnvelope(request, new NotImplementedError(request.kind));
}
op = std;
argSchema = OP_SCHEMAS[std].args;
resultSchema = OP_SCHEMAS[std].result;
const handler = config[std];
if (handler === undefined) {
return makeErrorEnvelope(request, new NotImplementedError(std));
}
}
// 2. Args validation (wire shape)
const argParse = argSchema.safeParse(request.args);
if (!argParse.success) {
const issue = argParse.error.issues[0];
return makeErrorEnvelope(
request,
new InvalidArgsError(
issue?.message ?? 'invalid arguments',
issue?.path.join('.') || undefined,
),
);
}
const parsedArgs = argParse.data as unknown;
// 2b. Custom op resolution
let resolvedOpKind: string = op;
if (op === 'custom') {
const customArgs = parsedArgs as CustomArgs;
customHandler = customRegistrations[customArgs.name];
if (customHandler === undefined) {
return makeErrorEnvelope(
request,
new NotImplementedError(`custom:${customArgs.name}`),
);
}
resolvedOpKind = `custom:${customArgs.name}`;
// Validate inner payload against the custom op's args schema
const payloadParse = customHandler.args.safeParse(customArgs.payload);
if (!payloadParse.success) {
const issue = payloadParse.error.issues[0];
return makeErrorEnvelope(
request,
new InvalidArgsError(
issue?.message ?? 'invalid custom-op payload',
issue?.path.join('.') || undefined,
),
);
}
// Replace payload with validated value (Zod may apply defaults).
(parsedArgs as CustomArgs).payload = payloadParse.data;
}
// 3. Path validation (skip ops without a path)
let primaryPath = '';
if (op === 'move') {
primaryPath = (parsedArgs as MoveArgs).src;
} else if (op !== 'custom' && 'path' in (parsedArgs as object)) {
primaryPath = (parsedArgs as { path: string }).path;
}
let normalizedPath = primaryPath;
if (primaryPath !== '') {
const validated = validatePath(primaryPath, config.pathPolicy);
if (!validated.ok) {
return makeErrorEnvelope(request, new PathValidationError(validated.reason));
}
normalizedPath = validated.normalized;
if (op === 'move') {
const dstValid = validatePath((parsedArgs as MoveArgs).dst, config.pathPolicy);
if (!dstValid.ok) {
return makeErrorEnvelope(
request,
new PathValidationError(dstValid.reason, 'dst'),
);
}
(parsedArgs as MoveArgs).src = normalizedPath;
(parsedArgs as MoveArgs).dst = dstValid.normalized;
} else {
(parsedArgs as { path: string }).path = normalizedPath;
}
}
// 4. Rate-limit acquire
const opCostKey = op === 'custom' ? 'custom' : op;
const customCost = customHandler?.cost;
const estimatedBytes = estimateBytes(op === 'custom' ? 'custom' : op, parsedArgs);
try {
if (customCost !== undefined) {
// Custom op-specific cost: acquire that many op-tokens manually.
// Fall back to standard `custom` bucket cost for non-overridden.
rateLimiter.acquire(from, opCostKey, estimatedBytes);
// For overridden cost > 1, deduct extra tokens from the same bucket.
// The simplest route: re-acquire (cost - 1) more.
for (let i = 1; i < customCost; i++) {
rateLimiter.acquire(from, opCostKey, 0);
}
} else {
rateLimiter.acquire(from, opCostKey, estimatedBytes);
}
} catch (err) {
metrics(METRIC_RATE_LIMIT_REJECT_TOTAL, 1, { op: resolvedOpKind });
return makeErrorEnvelope(request, err);
}
// 5. Idempotency (mutations only)
const isMutation = MUTATION_OPS.has(opCostKey);
let commitIdem: ((response: unknown) => void) | null = null;
let abandonIdem: (() => void) | null = null;
if (isMutation && request.idempotencyKey !== undefined) {
try {
const begin = idempotency.begin(from, request.idempotencyKey, parsedArgs);
if (begin.status === 'replay') {
rateLimiter.release(from, opCostKey, estimatedBytes);
metrics(METRIC_IDEMPOTENCY_HIT_TOTAL, 1, { op: resolvedOpKind });
return makeResponseEnvelope(request, begin.response);
}
if (begin.status === 'wait') {
const cached = await begin.promise;
rateLimiter.release(from, opCostKey, estimatedBytes);
metrics(METRIC_IDEMPOTENCY_HIT_TOTAL, 1, { op: resolvedOpKind });
return makeResponseEnvelope(request, cached);
}
commitIdem = begin.commit;
abandonIdem = begin.abandon;
} catch (err) {
rateLimiter.release(from, opCostKey, estimatedBytes);
if (
err !== null &&
typeof err === 'object' &&
(err as { code?: string }).code === 'SHADE_FS_IDEMPOTENCY_CONFLICT'
) {
metrics(METRIC_IDEMPOTENCY_CONFLICT_TOTAL, 1, { op: resolvedOpKind });
}
return makeErrorEnvelope(request, err);
}
}
// 6. Build context + abort controller
const controller = new AbortController();
inflightCancellers.set(request.id, controller);
const ctx = buildOpContext({
op: op === 'custom' ? (resolvedOpKind as `custom:${string}`) : op,
path: normalizedPath,
parsedArgs,
sender: from,
signal: controller.signal,
idempotencyKey: request.idempotencyKey,
attemptNumber: request.attempt ?? 1,
shade,
});
// 7. Fingerprint gate
if (config.requireFingerprintVerifiedFor !== undefined) {
let gate: 'required' | 'optional' | 'reject';
try {
gate = await config.requireFingerprintVerifiedFor(ctx as OpContext<unknown>);
} catch (err) {
cleanup({ release: true });
return makeErrorEnvelope(request, err);
}
if (gate === 'reject') {
cleanup({ release: true });
metrics(METRIC_FINGERPRINT_REJECT_TOTAL, 1, { op: resolvedOpKind, gate: 'reject' });
return makeErrorEnvelope(
request,
new FingerprintRequiredError('operation rejected by fingerprint policy'),
);
}
if (gate === 'required') {
let verified = false;
try {
verified = config.isFingerprintVerified !== undefined
? Boolean(await config.isFingerprintVerified(from))
: false;
} catch (err) {
cleanup({ release: true });
return makeErrorEnvelope(request, err);
}
if (!verified) {
cleanup({ release: true });
metrics(METRIC_FINGERPRINT_REJECT_TOTAL, 1, { op: resolvedOpKind, gate: 'required' });
return makeErrorEnvelope(request, new FingerprintRequiredError());
}
}
}
// 8. beforeOp
try {
if (config.beforeOp !== undefined) {
await config.beforeOp(ctx as OpContext<unknown>);
}
} catch (err) {
cleanup({ release: true });
return makeErrorEnvelope(request, err);
}
// 9. Run handler with timeout race — adapting I/O ops as needed.
const timeoutMs = Math.min(
request.deadlineMs ?? defaultTimeoutMs,
defaultTimeoutMs,
);
let wireResult: unknown;
const startedAt = Date.now();
try {
wireResult = await runWithTimeout(
() => invokeOpHandler({
op,
stdHandler: op === 'custom' ? undefined : (config[op] as unknown),
customHandler,
ctx: ctx as OpContext<unknown>,
parsedArgs,
sender: from,
signal: controller.signal,
streamsBridge: config.streamsBridge,
ioTimeoutMs,
}),
timeoutMs,
controller,
);
} catch (err) {
const durationMs = Date.now() - startedAt;
metrics(METRIC_OP_DURATION_MS, durationMs, { op: resolvedOpKind, result: 'error' });
metrics(METRIC_OP_TOTAL, 1, { op: resolvedOpKind, result: 'error' });
cleanup({ release: true });
if (config.onError !== undefined) {
try {
config.onError(err, ctx as OpContext<unknown>);
} catch (hookErr) {
console.error('[FileHandler] onError hook threw:', hookErr);
}
}
return makeErrorEnvelope(request, err);
}
// 10. Custom-op response validation against the registered schema.
if (op === 'custom' && customHandler !== undefined) {
const innerParse = customHandler.response.safeParse(wireResult);
if (!innerParse.success) {
cleanup({ release: true });
return makeErrorEnvelope(
request,
new CustomOpRejectedError(
`custom-op response shape rejected by registered schema: ${innerParse.error.issues[0]?.message ?? 'unknown'}`,
),
);
}
wireResult = { result: innerParse.data };
}
// 11. Defensive response validation against the wire schema.
const resultParse = resultSchema.safeParse(wireResult);
if (!resultParse.success) {
cleanup({ release: true });
return makeErrorEnvelope(
request,
new InvalidArgsError(
`handler for ${resolvedOpKind} returned invalid response shape`,
),
);
}
// 12. afterOp
try {
if (config.afterOp !== undefined) {
await config.afterOp(ctx as OpContext<unknown>, resultParse.data);
}
} catch (err) {
cleanup({ release: true });
return makeErrorEnvelope(request, err);
}
// 13. Commit idempotency + emit metrics
commitIdem?.(resultParse.data);
cleanup({ release: false });
const durationMs = Date.now() - startedAt;
metrics(METRIC_OP_DURATION_MS, durationMs, { op: resolvedOpKind, result: 'ok' });
metrics(METRIC_OP_TOTAL, 1, { op: resolvedOpKind, result: 'ok' });
if (estimatedBytes > 0) {
// Inbound bytes (write) vs outbound (read) — both reuse the same
// pre-call `estimatedBytes`, since post-execution reconciliation
// would require deeper plumbing.
const direction = op === 'write' ? METRIC_BYTES_IN : op === 'read' ? METRIC_BYTES_OUT : null;
if (direction !== null) {
metrics(direction, estimatedBytes, { op: resolvedOpKind });
}
}
return makeResponseEnvelope(request, resultParse.data);
function cleanup(opts: { release: boolean }): void {
inflightCancellers.delete(request.id);
if (opts.release) {
abandonIdem?.();
rateLimiter.release(from, opCostKey, estimatedBytes);
}
}
}
function handleCancel(_from: string, cancel: RpcCancel): void {
const controller = inflightCancellers.get(cancel.id);
if (controller !== undefined) {
controller.abort(new CancelledError(cancel.reason ?? 'cancelled by sender'));
inflightCancellers.delete(cancel.id);
}
}
function destroy(): void {
for (const c of inflightCancellers.values()) {
c.abort(new CancelledError('handler destroyed'));
}
inflightCancellers.clear();
}
return Object.assign({ handleRequest, handleCancel, destroy }, {
[INTERNAL_SYMBOL]: { idempotency, rateLimiter },
});
}
export const INTERNAL_SYMBOL = Symbol.for('@shade/files/internal');
// ─── Op invoker (handles I/O adapters) ───────────────────────
interface InvokeArgs {
op: StandardOp | 'custom';
stdHandler: unknown;
customHandler: CustomOpRegistrations[string] | undefined;
ctx: OpContext<unknown>;
parsedArgs: unknown;
sender: string;
signal: AbortSignal;
streamsBridge: ServerStreamsBridge | undefined;
ioTimeoutMs: number;
}
async function invokeOpHandler(args: InvokeArgs): Promise<unknown> {
const { op, stdHandler, customHandler, ctx, parsedArgs, sender, signal, streamsBridge, ioTimeoutMs } = args;
const adapterDeps = { streamsBridge, sender, signal, ioTimeoutMs };
switch (op) {
case 'write': {
const wireArgs = parsedArgs as WriteArgs;
const { userArgs, awaitTransferDone } = await adaptWriteArgs(wireArgs, adapterDeps);
const userCtx = { ...ctx, args: userArgs } as OpContext<UserWriteArgs>;
const userResult = await (stdHandler as (c: OpContext<UserWriteArgs>) => Promise<WriteResult>)(userCtx);
await awaitTransferDone();
return userResult;
}
case 'read': {
const readArgs = parsedArgs as ReadArgs;
const userResult = await (stdHandler as (c: OpContext<ReadArgs>) => Promise<UserReadResult>)(ctx as OpContext<ReadArgs>);
return await adaptReadResult(userResult, readArgs, adapterDeps);
}
case 'getThumbnail': {
const userResult = await (stdHandler as (c: OpContext<GetThumbnailArgs>) => Promise<UserThumbnailResult>)(ctx as OpContext<GetThumbnailArgs>);
return adaptThumbnailResult(userResult);
}
case 'custom': {
if (customHandler === undefined) {
throw new NotImplementedError('custom op without registration');
}
const customArgs = parsedArgs as CustomArgs;
const innerCtx = { ...ctx, args: customArgs } as OpContext<{ name: string; payload: unknown }>;
// Pass the validated inner payload as the first arg, the OpContext as the second.
return await customHandler.handler(
customArgs.payload,
innerCtx as OpContext<{ name: string; payload: unknown }>,
);
}
default:
// Pass-through for list/stat/mkdir/delete/move.
return await (stdHandler as (c: OpContext<unknown>) => Promise<unknown>)(ctx);
}
}
// ─── Helpers ─────────────────────────────────────────────────
function makeResponseEnvelope(req: RpcRequest, result: unknown): RpcResponse {
return {
kind: responseKindOf(req.kind),
id: req.id,
result,
};
}
function makeErrorEnvelope(req: RpcRequest, err: unknown): RpcError {
return {
kind: 'shade.fs.error/v1',
id: req.id,
error: payloadFromError(err),
};
}
function estimateBytes(op: StandardOp | 'custom', args: unknown): number {
if (op === 'write') {
const w = args as { kind: 'inline' | 'streams'; bytesB64?: string; size?: number };
if (w.kind === 'inline' && typeof w.bytesB64 === 'string') {
return Math.floor((w.bytesB64.length * 3) / 4);
}
return w.size ?? 0;
}
if (op === 'read') {
const r = args as { range?: { start: number; end: number } };
if (r.range !== undefined) return r.range.end - r.range.start;
return 0;
}
return 0;
}
async function runWithTimeout<T>(
fn: () => Promise<T>,
timeoutMs: number,
controller: AbortController,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort(new Error('timeout'));
reject(new (FileError as unknown as { new (p: { code: string; message: string }): FileError })({
code: 'OPERATION_TIMEOUT',
message: `operation timed out after ${timeoutMs}ms`,
}));
}, timeoutMs);
});
try {
return await Promise.race([fn(), timeout]);
} finally {
if (timer !== null) clearTimeout(timer);
}
}

View File

@@ -0,0 +1,160 @@
import { hashArgs, bytesToHex } from '../protocol/canonical.js';
import { IdempotencyConflictError } from '../schemas/errors.js';
interface Entry {
argsHash: string;
/** `undefined` while inflight; resolved value once handler completes. */
status: 'inflight' | 'done';
promise?: Promise<unknown>;
response?: unknown;
insertedAt: number;
lastAccessAt: number;
}
export interface IdempotencyCacheOptions {
/** Time in ms after which an entry is eligible for eviction. Default 1h. */
ttlMs?: number;
/** Per-sender hard cap on cached entries; oldest-by-insert evicted first. */
maxEntriesPerSender?: number;
}
/**
* Per-sender LRU + TTL cache for idempotent RPC calls.
*
* - On lookup, returns cached response if `argsHash` matches the original
* call. Throws `IdempotencyConflictError` on argsHash mismatch.
* - On concurrent retry of the same key (still inflight), the second
* caller awaits the first's promise — no double-execution.
* - LRU eviction caps memory at `maxEntriesPerSender` per sender.
*
* Keyed on `(senderAddress, idempotencyKey)`. Internally a 2-level map.
*/
export class IdempotencyCache {
private readonly ttlMs: number;
private readonly maxPerSender: number;
private readonly bySender = new Map<string, Map<string, Entry>>();
constructor(opts: IdempotencyCacheOptions = {}) {
this.ttlMs = opts.ttlMs ?? 60 * 60 * 1000;
this.maxPerSender = opts.maxEntriesPerSender ?? 10_000;
}
/**
* Begin (or rejoin) an idempotent operation. Returns:
* - `{ status: 'fresh', commit }` — handler should run, then call `commit(response)`.
* - `{ status: 'wait', promise }` — concurrent retry; await the in-flight promise.
* - `{ status: 'replay', response }` — completed earlier; return cached.
*
* Mismatched argsHash throws `IdempotencyConflictError` immediately.
*/
begin(
sender: string,
key: string,
args: unknown,
):
| { status: 'fresh'; commit: (response: unknown) => void; abandon: () => void }
| { status: 'wait'; promise: Promise<unknown> }
| { status: 'replay'; response: unknown } {
const argsHash = bytesToHex(hashArgs(args));
const senderMap = this.getOrCreate(sender);
this.evictExpired(senderMap);
const existing = senderMap.get(key);
if (existing !== undefined) {
if (existing.argsHash !== argsHash) {
throw new IdempotencyConflictError();
}
existing.lastAccessAt = Date.now();
if (existing.status === 'done') {
return { status: 'replay', response: existing.response };
}
// Inflight — return the same promise so concurrent retries de-dupe.
return { status: 'wait', promise: existing.promise! };
}
let resolveInflight: (value: unknown) => void = () => {};
let rejectInflight: (err: unknown) => void = () => {};
const inflightPromise = new Promise<unknown>((resolve, reject) => {
resolveInflight = resolve;
rejectInflight = reject;
});
// Suppress unhandled-rejection warnings when no concurrent retry is
// awaiting this promise. Real waiters attach their own catch via
// `await begin().promise` and still see the rejection.
inflightPromise.catch(() => {
/* swallow — see comment above */
});
const entry: Entry = {
argsHash,
status: 'inflight',
promise: inflightPromise,
insertedAt: Date.now(),
lastAccessAt: Date.now(),
};
senderMap.set(key, entry);
this.evictOverflow(senderMap);
const commit = (response: unknown): void => {
entry.status = 'done';
entry.response = response;
entry.lastAccessAt = Date.now();
resolveInflight(response);
};
const abandon = (): void => {
// Failed/cancelled call — remove from cache so retries can proceed.
senderMap.delete(key);
rejectInflight(new Error('idempotency abandoned'));
};
return { status: 'fresh', commit, abandon };
}
/** Manual prune; called by the periodic retention job. */
prune(now: number = Date.now()): number {
let removed = 0;
for (const [sender, senderMap] of this.bySender) {
for (const [key, entry] of senderMap) {
if (now - entry.insertedAt > this.ttlMs) {
senderMap.delete(key);
removed++;
}
}
if (senderMap.size === 0) this.bySender.delete(sender);
}
return removed;
}
/** Total cached entries across all senders (mainly for tests/metrics). */
size(): number {
let total = 0;
for (const m of this.bySender.values()) total += m.size;
return total;
}
private getOrCreate(sender: string): Map<string, Entry> {
let m = this.bySender.get(sender);
if (m === undefined) {
m = new Map();
this.bySender.set(sender, m);
}
return m;
}
private evictExpired(senderMap: Map<string, Entry>): void {
const now = Date.now();
for (const [key, entry] of senderMap) {
if (now - entry.insertedAt > this.ttlMs) senderMap.delete(key);
}
}
private evictOverflow(senderMap: Map<string, Entry>): void {
if (senderMap.size <= this.maxPerSender) return;
// Map iteration order = insertion order; first key is oldest.
const overflow = senderMap.size - this.maxPerSender;
let i = 0;
for (const key of senderMap.keys()) {
if (i >= overflow) break;
senderMap.delete(key);
i++;
}
}
}

View File

@@ -0,0 +1,229 @@
/**
* Adapt between wire-shaped I/O ops and user-handler-shaped I/O ops.
*
* client → server (write): WriteArgs (wire) → UserWriteArgs (handler)
* server → client (read): UserReadResult (handler) → ReadResult (wire)
* server → client (thumb): UserThumbnailResult → GetThumbnailResult
*
* The streams-bridge is consulted whenever we're crossing the > 256 KiB
* boundary: outbound reads kick a new transfer; inbound writes await one.
*/
import { sha256 } from '@noble/hashes/sha2.js';
import type {
GetThumbnailResult,
ReadArgs,
ReadResult,
WriteArgs,
} from '../schemas/ops.js';
import { base64ToBytes, bytesToBase64, bytesToHex } from '../protocol/canonical.js';
import { INLINE_THRESHOLD } from '../client/inline-threshold.js';
import {
ConflictError,
InternalFileError,
InvalidArgsError,
} from '../schemas/errors.js';
import type {
UserReadResult,
UserThumbnailResult,
UserWriteArgs,
} from './io-types.js';
import type { ServerStreamsBridge } from './streams-bridge.js';
export interface IoAdapterDeps {
streamsBridge: ServerStreamsBridge | undefined;
sender: string;
signal: AbortSignal;
/** Hard deadline for streams-bridge awaits / outbound transfers. */
ioTimeoutMs: number;
}
// ─── write: wire args → user args ────────────────────────────
/**
* Convert validated `WriteArgs` (wire shape) into `UserWriteArgs`. For
* streams writes, this awaits the matching inbound transfer via the
* streams-bridge and synthesizes a `ReadableStream` for the user handler.
*/
export async function adaptWriteArgs(
wireArgs: WriteArgs,
deps: IoAdapterDeps,
): Promise<{
userArgs: UserWriteArgs;
/** Resolves once the inbound transfer (if any) has completed. */
awaitTransferDone: () => Promise<void>;
}> {
if (wireArgs.kind === 'inline') {
const bytes = base64ToBytes(wireArgs.bytesB64);
if (bytes.byteLength > INLINE_THRESHOLD) {
throw new InvalidArgsError(
`inline write exceeds ${INLINE_THRESHOLD}-byte threshold (got ${bytes.byteLength})`,
'bytesB64',
);
}
const hashHex = bytesToHex(sha256(bytes));
const userArgs: UserWriteArgs = {
path: wireArgs.path,
overwrite: wireArgs.overwrite,
...(wireArgs.contentType !== undefined ? { contentType: wireArgs.contentType } : {}),
content: {
kind: 'inline',
bytes,
size: bytes.byteLength,
sha256: hashHex,
},
};
return { userArgs, awaitTransferDone: async () => undefined };
}
// Streams write — must have a streams-bridge configured.
if (deps.streamsBridge === undefined) {
throw new InternalFileError(
'streams-bridge not configured: cannot accept streamed write',
);
}
const parked = await deps.streamsBridge.awaitWrite(wireArgs.writeId, {
expectedFrom: deps.sender,
signal: deps.signal,
timeoutMs: deps.ioTimeoutMs,
});
// sha256 from the inbound transfer's done() — user handler can `await`
// it immediately after draining the readable.
const sha256Promise = parked.done.then((r) => r.sha256);
sha256Promise.catch(() => {
/* swallow until consumer awaits */
});
// Cancellation: if the user handler aborts (or the dispatcher times out),
// also tear down the inbound transfer.
const onAbort = (): void => {
void parked.handle.abort('user-cancel').catch(() => undefined);
};
if (deps.signal.aborted) onAbort();
else deps.signal.addEventListener('abort', onAbort, { once: true });
// The dispatcher calls this AFTER the user handler resolves to ensure
// the transfer is fully drained (so any wire-level integrity errors
// surface here before we send the RPC ack to the client).
const awaitTransferDone = async (): Promise<void> => {
try {
await parked.done;
} finally {
deps.signal.removeEventListener('abort', onAbort);
}
};
const userArgs: UserWriteArgs = {
path: wireArgs.path,
overwrite: wireArgs.overwrite,
...(wireArgs.contentType !== undefined ? { contentType: wireArgs.contentType } : {}),
content: {
kind: 'streams',
stream: parked.readable,
size: wireArgs.size,
sha256: sha256Promise,
},
};
return { userArgs, awaitTransferDone };
}
// ─── read: user result → wire result ─────────────────────────
/**
* Convert a user-supplied `UserReadResult` into a wire `ReadResult`. For
* streams results, this kicks an outbound `@shade/transfer` and embeds the
* resulting `streamId` in the response so the client-side bridge can
* subscribe.
*/
export async function adaptReadResult(
userResult: UserReadResult,
args: ReadArgs,
deps: IoAdapterDeps,
): Promise<ReadResult> {
if (userResult.kind === 'inline') {
if (userResult.bytes.byteLength > INLINE_THRESHOLD) {
throw new InternalFileError(
`inline read result exceeds ${INLINE_THRESHOLD} bytes (got ${userResult.bytes.byteLength})`,
);
}
const sha256Hex = userResult.sha256 ?? bytesToHex(sha256(userResult.bytes));
return {
kind: 'inline',
bytesB64: bytesToBase64(userResult.bytes),
size: userResult.bytes.byteLength,
sha256: sha256Hex,
...(userResult.contentType !== undefined ? { contentType: userResult.contentType } : {}),
};
}
if (deps.streamsBridge === undefined) {
throw new InternalFileError(
'streams-bridge not configured: cannot ship streamed read result',
);
}
const { readStreamId } = await deps.streamsBridge.initiateRead({
peer: deps.sender,
stream: userResult.stream,
size: userResult.size,
...(userResult.contentType !== undefined ? { contentType: userResult.contentType } : {}),
name: args.path,
signal: deps.signal,
});
return {
kind: 'streams',
streamId: readStreamId,
size: userResult.size,
sha256: userResult.sha256,
...(userResult.contentType !== undefined ? { contentType: userResult.contentType } : {}),
};
}
// ─── getThumbnail: user result → wire result ─────────────────
const FORMAT_MAGIC: Record<string, Uint8Array> = {
png: new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
jpeg: new Uint8Array([0xff, 0xd8, 0xff]),
webp: new Uint8Array([0x52, 0x49, 0x46, 0x46]), // 'RIFF', then size, then 'WEBP'
};
const WEBP_HEAD = new Uint8Array([0x57, 0x45, 0x42, 0x50]); // 'WEBP' at offset 8
/**
* Validate a user thumbnail result for format magic-bytes match. The user
* MAY produce any image format internally, but this guards against
* misclassification (e.g. claiming PNG with JPEG bytes), which can confuse
* downstream rendering and security scanners.
*/
function checkFormatMagic(bytes: Uint8Array, format: 'png' | 'webp' | 'jpeg'): boolean {
const magic = FORMAT_MAGIC[format];
if (magic === undefined) return false;
if (bytes.byteLength < magic.byteLength) return false;
for (let i = 0; i < magic.byteLength; i++) {
if (bytes[i] !== magic[i]) return false;
}
if (format === 'webp') {
if (bytes.byteLength < 12) return false;
for (let i = 0; i < 4; i++) {
if (bytes[8 + i] !== WEBP_HEAD[i]) return false;
}
}
return true;
}
export function adaptThumbnailResult(
userResult: UserThumbnailResult,
): GetThumbnailResult {
if (!checkFormatMagic(userResult.bytes, userResult.format)) {
throw new ConflictError(
`thumbnail bytes do not match declared format=${userResult.format}`,
);
}
const hash = userResult.sha256 ?? bytesToHex(sha256(userResult.bytes));
return {
bytesB64: bytesToBase64(userResult.bytes),
format: userResult.format,
width: userResult.width,
height: userResult.height,
sha256: hash,
};
}

View File

@@ -0,0 +1,102 @@
/**
* Server-facing types for the I/O ops (`read`, `write`, `getThumbnail`).
*
* These differ from the wire schemas in `schemas/ops.ts` so user-supplied
* handlers see clean, typed values (`Uint8Array`, `ReadableStream`) instead
* of base64 strings and opaque transfer ids. The dispatcher in
* `server/handler.ts` adapts between the two.
*/
import type { ThumbnailSize, WriteResult } from '../schemas/ops.js';
// ─── read handler types ──────────────────────────────────────
/**
* Value returned by a user-supplied `read` handler.
*
* - `inline`: the bytes are returned in-band; the dispatcher base64-encodes
* them and computes the sha256 if the user did not provide one. Inline
* reads MUST be ≤ 256 KiB plaintext.
* - `streams`: the user provides a `ReadableStream`, the declared `size`,
* and a precomputed `sha256` (e.g. cached on the file's metadata row).
* The dispatcher initiates an outbound `@shade/transfer` transfer and
* echoes the transfer's `streamId` back to the client over RPC so the
* client-side bridge can subscribe.
*/
export type UserReadResult =
| UserReadResultInline
| UserReadResultStreams;
export interface UserReadResultInline {
kind: 'inline';
bytes: Uint8Array;
/** Optional — dispatcher computes if absent. */
sha256?: string;
contentType?: string;
}
export interface UserReadResultStreams {
kind: 'streams';
stream: ReadableStream<Uint8Array>;
size: number;
/** REQUIRED — the dispatcher cannot synchronously hash a stream. */
sha256: string;
contentType?: string;
}
// ─── write handler types ─────────────────────────────────────
/**
* Value passed to a user-supplied `write` handler.
*
* The dispatcher decodes the wire `WriteArgs` and (for `streams`) parks
* the inbound transfer via the streams-bridge, then synthesizes this
* cleaner shape for the user.
*/
export interface UserWriteArgs {
path: string;
contentType?: string;
overwrite: boolean;
content: UserWriteContent;
}
export type UserWriteContent =
| UserWriteContentInline
| UserWriteContentStreams;
export interface UserWriteContentInline {
kind: 'inline';
bytes: Uint8Array;
size: number;
/** sha256 of `bytes`, computed by the dispatcher. */
sha256: string;
}
export interface UserWriteContentStreams {
kind: 'streams';
/** Plaintext stream. Closes when the inbound `@shade/transfer` completes. */
stream: ReadableStream<Uint8Array>;
/** Declared plaintext size from the client's RPC args. */
size: number;
/**
* Promise that resolves with the transfer-verified sha256 once the entire
* stream has been received. Use this to attach a content hash to the
* user's storage record.
*/
sha256: Promise<string>;
}
// ─── getThumbnail handler types ──────────────────────────────
/** Value returned by a user-supplied `getThumbnail` handler. */
export interface UserThumbnailResult {
bytes: Uint8Array;
format: 'png' | 'webp' | 'jpeg';
width: number;
height: number;
/** Optional — dispatcher computes if absent. */
sha256?: string;
}
// ─── Re-exports ──────────────────────────────────────────────
export type { ThumbnailSize, WriteResult };

View File

@@ -0,0 +1,25 @@
/**
* Standard metric names emitted by the dispatcher.
*
* The user-supplied `onMetric(name, value, tags)` callback is vendor-neutral —
* pipe to Prometheus, OpenTelemetry, statsd, or just append to a log file.
*/
export const METRIC_OP_DURATION_MS = 'shade_files_op_duration_ms';
export const METRIC_OP_TOTAL = 'shade_files_op_total';
export const METRIC_BYTES_IN = 'shade_files_bytes_in';
export const METRIC_BYTES_OUT = 'shade_files_bytes_out';
export const METRIC_IDEMPOTENCY_HIT_TOTAL = 'shade_files_idempotency_hit_total';
export const METRIC_IDEMPOTENCY_CONFLICT_TOTAL = 'shade_files_idempotency_conflict_total';
export const METRIC_RATE_LIMIT_REJECT_TOTAL = 'shade_files_rate_limit_reject_total';
export const METRIC_FINGERPRINT_REJECT_TOTAL = 'shade_files_fingerprint_reject_total';
export const METRIC_SIGNATURE_REJECT_TOTAL = 'shade_files_signature_reject_total';
/** Tags attached to every metric event. */
export type MetricTags = Record<string, string | number | boolean>;
/** User-supplied metric sink. Synchronous to keep hot-path fast. */
export type MetricSink = (name: string, value: number, tags: MetricTags) => void;
/** No-op sink used when the user doesn't supply one. */
export const NOOP_METRIC_SINK: MetricSink = () => undefined;

View File

@@ -0,0 +1,95 @@
import {
decodePercentEscapes,
isPathInside,
posixNormalize,
} from '../utils/path.js';
export interface PathPolicy {
/** Chroot-style absolute root. All paths must lie inside this directory. */
rootScope?: string;
/** Reject `..` segments after normalization. Default true. */
forbidTraversal?: boolean;
/** Hard cap on raw-input length (post-decode). Default 4096. */
maxLength?: number;
/** Allow symlink components. Default false (consumer enforces realpath if true). */
allowSymlinks?: boolean;
/** App-specific extra check. Returns 'allow' | 'reject'. */
extra?: (normalizedPath: string) => 'allow' | 'reject';
}
export type PathValidationResult =
| { ok: true; normalized: string }
| { ok: false; reason: string };
const DEFAULT_MAX_LENGTH = 4096;
/** Bytes that must never appear in any path. */
const FORBIDDEN_RE = /[\x00-\x08\x0a-\x1f\x7f\\]/;
/**
* Validate and normalize a path against the configured policy.
*
* Performs (in order):
* 1. Length check on raw input.
* 2. Forbidden-bytes check on raw input (NUL/CR/LF/DEL/backslash).
* 3. Percent-decode (defense against `%2e%2e` smuggling).
* 4. Forbidden-bytes check on decoded form.
* 5. POSIX normalization.
* 6. `..` traversal rejection.
* 7. Root-scope boundary check.
* 8. Consumer-supplied `extra` predicate.
*
* Returns the normalized path on success. The handler MUST use the
* normalized form, not the raw input — the raw could trip TOCTOU on
* the caller's filesystem.
*/
export function validatePath(
rawPath: string,
policy: PathPolicy = {},
): PathValidationResult {
const maxLength = policy.maxLength ?? DEFAULT_MAX_LENGTH;
const forbidTraversal = policy.forbidTraversal ?? true;
if (typeof rawPath !== 'string') {
return { ok: false, reason: 'path must be a string' };
}
if (rawPath.length === 0) {
return { ok: false, reason: 'path is empty' };
}
if (rawPath.length > maxLength) {
return { ok: false, reason: `path exceeds ${maxLength} characters` };
}
if (FORBIDDEN_RE.test(rawPath)) {
return { ok: false, reason: 'path contains forbidden control or backslash characters' };
}
const decoded = decodePercentEscapes(rawPath);
if (FORBIDDEN_RE.test(decoded)) {
return { ok: false, reason: 'percent-decoded path contains forbidden characters' };
}
if (!decoded.startsWith('/')) {
return { ok: false, reason: 'path must be absolute' };
}
if (forbidTraversal) {
// Detect `..` segments BEFORE normalization (otherwise they'd be silently
// collapsed). Handles both raw and decoded forms.
if (/(^|\/)\.\.(\/|$)/.test(rawPath) || /(^|\/)\.\.(\/|$)/.test(decoded)) {
return { ok: false, reason: 'path contains `..` traversal segment' };
}
}
const normalized = posixNormalize(decoded);
if (policy.rootScope !== undefined) {
const root = posixNormalize(policy.rootScope);
if (!isPathInside(normalized, root)) {
return { ok: false, reason: 'path is outside root scope' };
}
}
if (policy.extra !== undefined && policy.extra(normalized) === 'reject') {
return { ok: false, reason: 'path rejected by policy.extra' };
}
return { ok: true, normalized };
}

View File

@@ -0,0 +1,157 @@
import { FsRateLimitError, QuotaExceededError } from '../schemas/errors.js';
export interface RateLimitConfig {
/** Cap on op-tokens per minute per sender. Default 600. */
maxOpsPerMinutePerSender?: number;
/** Cap on byte-tokens per hour per sender. Default 10 GiB. */
maxBytesPerHourPerSender?: number;
/** Per-op token cost. Default 1 per op except write=5, delete=3. */
opCost?: Partial<Record<string, number>> & { default?: number };
}
interface Bucket {
tokens: number;
capacity: number;
/** Tokens replenished per millisecond. */
refillRate: number;
lastRefill: number;
}
interface SenderBuckets {
ops: Bucket;
bytes: Bucket;
}
/**
* Two-bucket per-sender rate limiter. Each operation consumes:
* - `opCost(op)` from the ops bucket.
* - `estimatedBytes` from the bytes bucket (post-call reconciliation possible).
*
* Rejection throws either `FsRateLimitError` (op-bucket) or `QuotaExceededError`
* (bytes-bucket), each with a `retryAfterMs` hint.
*
* Cancellation MUST call `release()` to return reserved tokens.
*/
export class RateLimiter {
private readonly opsCapacity: number;
private readonly opsRefillPerMs: number;
private readonly bytesCapacity: number;
private readonly bytesRefillPerMs: number;
private readonly opCost: Required<NonNullable<RateLimitConfig['opCost']>>;
private readonly bySender = new Map<string, SenderBuckets>();
constructor(config: RateLimitConfig = {}) {
this.opsCapacity = config.maxOpsPerMinutePerSender ?? 600;
this.opsRefillPerMs = this.opsCapacity / 60_000;
this.bytesCapacity = config.maxBytesPerHourPerSender ?? 10 * 1024 * 1024 * 1024;
this.bytesRefillPerMs = this.bytesCapacity / 3_600_000;
this.opCost = {
list: config.opCost?.list ?? 1,
stat: config.opCost?.stat ?? 1,
mkdir: config.opCost?.mkdir ?? 2,
delete: config.opCost?.delete ?? 3,
move: config.opCost?.move ?? 2,
read: config.opCost?.read ?? 1,
write: config.opCost?.write ?? 5,
getThumbnail: config.opCost?.getThumbnail ?? 2,
custom: config.opCost?.custom ?? 1,
default: config.opCost?.default ?? 1,
} as Required<NonNullable<RateLimitConfig['opCost']>>;
}
/**
* Acquire tokens for an operation. Throws if either bucket is empty;
* otherwise atomically deducts from both. `estimatedBytes` may be 0 for
* non-I/O ops.
*/
acquire(sender: string, op: string, estimatedBytes: number = 0): void {
const buckets = this.getBuckets(sender);
this.refill(buckets, Date.now());
const cost = this.costFor(op);
if (buckets.ops.tokens < cost) {
const need = cost - buckets.ops.tokens;
const retryMs = Math.ceil(need / this.opsRefillPerMs);
throw new FsRateLimitError(`op rate limit exceeded`, retryMs);
}
if (estimatedBytes > 0 && buckets.bytes.tokens < estimatedBytes) {
const need = estimatedBytes - buckets.bytes.tokens;
const retryMs = Math.ceil(need / this.bytesRefillPerMs);
throw new QuotaExceededError(`byte quota exceeded`, retryMs);
}
buckets.ops.tokens -= cost;
if (estimatedBytes > 0) buckets.bytes.tokens -= estimatedBytes;
}
/**
* Reconcile after the actual byte cost is known. `actualBytes` may be
* higher or lower than the estimate; caps to capacity on release.
*/
reconcile(sender: string, estimatedBytes: number, actualBytes: number): void {
if (estimatedBytes === actualBytes) return;
const buckets = this.getBuckets(sender);
const delta = estimatedBytes - actualBytes;
if (delta > 0) {
buckets.bytes.tokens = Math.min(buckets.bytes.capacity, buckets.bytes.tokens + delta);
} else {
buckets.bytes.tokens = Math.max(0, buckets.bytes.tokens + delta);
}
}
/** Return tokens to both buckets (cancellation cleanup). */
release(sender: string, op: string, bytesReserved: number = 0): void {
const buckets = this.bySender.get(sender);
if (buckets === undefined) return;
const cost = this.costFor(op);
buckets.ops.tokens = Math.min(buckets.ops.capacity, buckets.ops.tokens + cost);
if (bytesReserved > 0) {
buckets.bytes.tokens = Math.min(buckets.bytes.capacity, buckets.bytes.tokens + bytesReserved);
}
}
/** Available token snapshot — for tests/metrics. */
snapshot(sender: string): { ops: number; bytes: number } | null {
const buckets = this.bySender.get(sender);
if (buckets === undefined) return null;
this.refill(buckets, Date.now());
return { ops: buckets.ops.tokens, bytes: buckets.bytes.tokens };
}
private costFor(op: string): number {
const explicit = (this.opCost as Record<string, number>)[op];
return explicit ?? this.opCost.default;
}
private getBuckets(sender: string): SenderBuckets {
let b = this.bySender.get(sender);
if (b !== undefined) return b;
const now = Date.now();
b = {
ops: {
tokens: this.opsCapacity,
capacity: this.opsCapacity,
refillRate: this.opsRefillPerMs,
lastRefill: now,
},
bytes: {
tokens: this.bytesCapacity,
capacity: this.bytesCapacity,
refillRate: this.bytesRefillPerMs,
lastRefill: now,
},
};
this.bySender.set(sender, b);
return b;
}
private refill(buckets: SenderBuckets, now: number): void {
const refillBucket = (b: Bucket): void => {
const elapsed = now - b.lastRefill;
if (elapsed <= 0) return;
b.tokens = Math.min(b.capacity, b.tokens + elapsed * b.refillRate);
b.lastRefill = now;
};
refillBucket(buckets.ops);
refillBucket(buckets.bytes);
}
}

View File

@@ -0,0 +1,289 @@
/**
* Server-side bridge between `@shade/files` content RPC ops and the
* `@shade/transfer` engine.
*
* Two responsibilities:
* 1. **Inbound writes (client → server, > 256 KiB).** When a client kicks
* `shade.upload(...)` with `userMetadata.shadeFilesWriteId = <id>`, this
* bridge intercepts the corresponding `IncomingTransfer` from
* `shade.onIncomingTransfer`, **immediately** calls `accept(...)` (the
* transfer engine requires synchronous accept — it rejects chunks that
* arrive before accept), pipes the plaintext into a TransformStream,
* and parks the readable side. The `write` RPC handler then awaits
* `awaitWrite(writeId, ...)` to pick up the readable, hand it to the
* user handler, and observe the transfer's done() result.
*
* 2. **Outbound reads (server → client, > 256 KiB).** When the user-supplied
* `read` handler returns `{ kind: 'streams', stream, size, sha256 }`,
* this bridge calls `shade.upload(...)` with
* `userMetadata.shadeFilesReadStreamId = <id>`. The id is then echoed
* back to the client in the `read` RPC response so the client-side
* bridge can subscribe to the matching incoming transfer.
*/
import type { IncomingTransfer, TransferHandle, TransferProgress } from '@shade/transfer';
import { generateRequestId } from '../protocol/correlate.js';
import { OperationTimeoutError } from '../schemas/errors.js';
/** Metadata key carried on inbound (client → server) write streams. */
export const META_KEY_WRITE_ID = 'shadeFilesWriteId';
/** Metadata key carried on outbound (server → client) read streams. */
export const META_KEY_READ_STREAM_ID = 'shadeFilesReadStreamId';
/**
* Subset of `Shade` we depend on. Lets us unit-test without a real Shade
* runtime and keeps the bridge framework-agnostic.
*/
export interface StreamsBridgeShade {
upload(opts: import('@shade/transfer').TransferOptions): Promise<TransferHandle>;
onIncomingTransfer(
handler: (incoming: IncomingTransfer) => void | Promise<void>,
): Promise<() => void>;
}
export interface ParkedWrite {
/** Sender address from the transfer engine. */
from: string;
/** Plaintext stream. The bridge already accepted the transfer; just consume. */
readable: ReadableStream<Uint8Array>;
/** Resolves when the transfer fully completes (sha256 available). */
done: Promise<{ sha256: string; bytesSent: number }>;
/** Underlying transfer handle — for abort propagation. */
handle: TransferHandle;
/** Unix epoch ms when the transfer arrived. Used for stale-eviction. */
arrivedAt: number;
}
export interface AwaitWriteOptions {
/** Sender address — must match `parked.from` for delivery. */
expectedFrom: string;
/** AbortSignal from the OpContext (cancellation/timeout). */
signal: AbortSignal;
/** Hard deadline (ms from now). Default 60_000. */
timeoutMs?: number;
}
export interface ServerStreamsBridge {
/**
* Wait for an inbound transfer carrying `userMetadata.shadeFilesWriteId
* === writeId` from `expectedFrom`. Resolves with the parked entry whose
* `readable` can be consumed by the user handler.
*
* If the transfer has already arrived (race: stream-init beat the RPC),
* resolves immediately. Otherwise awaits up to `timeoutMs`.
*/
awaitWrite(writeId: string, opts: AwaitWriteOptions): Promise<ParkedWrite>;
/**
* Initiate an outbound transfer to `peer` carrying the given stream.
* Stamps `userMetadata.shadeFilesReadStreamId = <streamId>`. Returns the
* new streamId so the caller can echo it back to the client over RPC.
*/
initiateRead(opts: {
peer: string;
stream: ReadableStream<Uint8Array>;
size: number;
contentType?: string;
name?: string;
signal?: AbortSignal;
onProgress?: (p: TransferProgress) => void;
}): Promise<{ readStreamId: string; handle: TransferHandle }>;
/** Tear down the incoming-transfer subscription and reject all pending awaits. */
destroy(): Promise<void>;
}
interface PendingWaiter {
resolve: (parked: ParkedWrite) => void;
reject: (err: unknown) => void;
expectedFrom: string;
timer: ReturnType<typeof setTimeout> | null;
abortListener: (() => void) | null;
signal: AbortSignal;
}
export interface CreateServerStreamsBridgeOptions {
/** Default deadline for `awaitWrite` if the caller doesn't supply one. */
defaultAwaitWriteTimeoutMs?: number;
/** How long to retain a parked transfer waiting for its RPC. Default 60_000. */
parkedWriteTtlMs?: number;
}
export async function createServerStreamsBridge(
shade: StreamsBridgeShade,
options: CreateServerStreamsBridgeOptions = {},
): Promise<ServerStreamsBridge> {
const parkedWriteTtlMs = options.parkedWriteTtlMs ?? 60_000;
const defaultAwaitTimeoutMs = options.defaultAwaitWriteTimeoutMs ?? 60_000;
const parked = new Map<string, ParkedWrite>();
const waiters = new Map<string, PendingWaiter>();
let destroyed = false;
const unsubscribeIncoming = await shade.onIncomingTransfer(async (incoming) => {
const writeId = incoming.metadata.userMetadata?.[META_KEY_WRITE_ID];
if (writeId === undefined) return; // not a files-bridge transfer; ignore
// Accept synchronously into a TransformStream so the engine can deliver
// chunks immediately. The readable side is parked for the matching RPC.
const ts = new TransformStream<Uint8Array, Uint8Array>();
let handle: TransferHandle;
try {
handle = await incoming.accept({
output: { kind: 'pipe', pipeTo: ts.writable },
});
} catch (err) {
// Accept failed — likely double-init or shutdown. Drop the parked
// entry; the awaiting RPC will time out.
console.error('[shade-files streams-bridge] accept failed:', err);
return;
}
const arrived: ParkedWrite = {
from: incoming.from,
readable: ts.readable,
done: handle.done().then((r) => ({ sha256: r.sha256, bytesSent: r.bytesSent })),
handle,
arrivedAt: Date.now(),
};
arrived.done.catch(() => {
/* swallow until consumer awaits */
});
const waiter = waiters.get(writeId);
if (waiter !== undefined) {
waiters.delete(writeId);
cleanupWaiter(waiter);
if (incoming.from !== waiter.expectedFrom) {
void handle.abort('sender-mismatch').catch(() => undefined);
waiter.reject(
new Error(
`streams-bridge: writeId=${writeId} delivered by ${incoming.from}, expected ${waiter.expectedFrom}`,
),
);
return;
}
waiter.resolve(arrived);
return;
}
// No waiter yet — park.
parked.set(writeId, arrived);
setTimeout(() => {
const stale = parked.get(writeId);
if (stale === arrived) {
parked.delete(writeId);
void handle.abort('rpc-timeout').catch(() => undefined);
}
}, parkedWriteTtlMs).unref?.();
});
function cleanupWaiter(w: PendingWaiter): void {
if (w.timer !== null) clearTimeout(w.timer);
if (w.abortListener !== null) {
w.signal.removeEventListener('abort', w.abortListener);
}
}
return {
async awaitWrite(writeId, opts) {
if (destroyed) throw new Error('streams-bridge: destroyed');
// 1. Already parked?
const ready = parked.get(writeId);
if (ready !== undefined) {
parked.delete(writeId);
if (ready.from !== opts.expectedFrom) {
void ready.handle.abort('sender-mismatch').catch(() => undefined);
throw new Error(
`streams-bridge: writeId=${writeId} delivered by ${ready.from}, expected ${opts.expectedFrom}`,
);
}
return ready;
}
if (waiters.has(writeId)) {
throw new Error(`streams-bridge: writeId=${writeId} already awaited`);
}
// 2. Park a waiter
const timeoutMs = opts.timeoutMs ?? defaultAwaitTimeoutMs;
return await new Promise<ParkedWrite>((resolve, reject) => {
const w: PendingWaiter = {
resolve,
reject,
expectedFrom: opts.expectedFrom,
timer: null,
abortListener: null,
signal: opts.signal,
};
w.timer = setTimeout(() => {
if (waiters.get(writeId) === w) {
waiters.delete(writeId);
cleanupWaiter(w);
reject(new OperationTimeoutError(`streams-bridge: writeId=${writeId} timed out after ${timeoutMs}ms`));
}
}, timeoutMs);
if (opts.signal.aborted) {
cleanupWaiter(w);
reject(opts.signal.reason ?? new Error('aborted before await'));
return;
}
const onAbort = (): void => {
if (waiters.get(writeId) === w) {
waiters.delete(writeId);
cleanupWaiter(w);
reject(opts.signal.reason ?? new Error('aborted'));
}
};
w.abortListener = onAbort;
opts.signal.addEventListener('abort', onAbort, { once: true });
waiters.set(writeId, w);
});
},
async initiateRead(opts) {
if (destroyed) throw new Error('streams-bridge: destroyed');
const readStreamId = generateRequestId();
const transferOpts: import('@shade/transfer').TransferOptions = {
to: opts.peer,
input: opts.stream,
metadata: {
...(opts.name !== undefined ? { name: opts.name } : {}),
...(opts.contentType !== undefined ? { contentType: opts.contentType } : {}),
sizeBytes: opts.size,
userMetadata: { [META_KEY_READ_STREAM_ID]: readStreamId },
},
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
...(opts.onProgress !== undefined ? { onProgress: opts.onProgress } : {}),
};
const handle = await shade.upload(transferOpts);
// The dispatcher returns the response immediately and doesn't itself
// await this handle — the receiver awaits its own handle.done() on
// the inbound side. Attach a no-op catch so engine teardown during
// an in-flight outbound transfer doesn't surface as an unhandled
// rejection in test environments.
handle.done().catch(() => undefined);
return { readStreamId, handle };
},
async destroy() {
if (destroyed) return;
destroyed = true;
unsubscribeIncoming();
// Reject all waiting RPCs
for (const w of waiters.values()) {
cleanupWaiter(w);
w.reject(new Error('streams-bridge: destroyed'));
}
waiters.clear();
// Abort all parked transfers
for (const p of parked.values()) {
try {
await p.handle.abort('bridge-destroyed');
} catch {
/* swallow */
}
}
parked.clear();
},
};
}

View File

@@ -0,0 +1,61 @@
/**
* Thumbnail format-hardening helper.
*
* Magic-byte verification for the three formats `@shade/files` advertises
* over the wire (`png`, `webp`, `jpeg`). Apps producing thumbnails outside
* the dispatcher (e.g. precomputing on disk) can call `assertThumbnailFormat`
* to fail-fast on mislabelled bytes before they reach the network.
*
* The same magic-byte table is used inside `io-adapters.ts:adaptThumbnailResult`
* to enforce the invariant on every outbound thumbnail.
*/
import { ConflictError } from '../schemas/errors.js';
const PNG_MAGIC = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const JPEG_MAGIC = new Uint8Array([0xff, 0xd8, 0xff]);
const WEBP_RIFF = new Uint8Array([0x52, 0x49, 0x46, 0x46]);
const WEBP_HEAD = new Uint8Array([0x57, 0x45, 0x42, 0x50]);
export type ThumbnailFormat = 'png' | 'webp' | 'jpeg';
/**
* Verify the bytes look like the declared format. Returns boolean — does
* not throw. For a throwing variant, use `assertThumbnailFormat`.
*/
export function isThumbnailFormat(bytes: Uint8Array, format: ThumbnailFormat): boolean {
switch (format) {
case 'png':
return startsWith(bytes, PNG_MAGIC);
case 'jpeg':
return startsWith(bytes, JPEG_MAGIC);
case 'webp': {
if (bytes.byteLength < 12) return false;
if (!startsWith(bytes, WEBP_RIFF)) return false;
for (let i = 0; i < 4; i++) {
if (bytes[8 + i] !== WEBP_HEAD[i]) return false;
}
return true;
}
}
}
/** Throws `ConflictError` if `bytes` doesn't match the declared `format`. */
export function assertThumbnailFormat(
bytes: Uint8Array,
format: ThumbnailFormat,
): void {
if (!isThumbnailFormat(bytes, format)) {
throw new ConflictError(
`thumbnail bytes do not match declared format=${format}`,
);
}
}
function startsWith(bytes: Uint8Array, prefix: Uint8Array): boolean {
if (bytes.byteLength < prefix.byteLength) return false;
for (let i = 0; i < prefix.byteLength; i++) {
if (bytes[i] !== prefix[i]) return false;
}
return true;
}

View File

@@ -0,0 +1,86 @@
/**
* Minimal POSIX path utilities — works in browser and Bun without depending
* on Node's built-in `node:path`. Only handles forward-slash absolute paths.
*/
/**
* Normalize a POSIX absolute path: collapse `//`, resolve `.` and `..`,
* preserve trailing slash semantics. Returns `'/'` for `''`/`'/'` input.
*
* Throws nothing on invalid input — sanitization is the caller's job
* (`validatePath` in `server/path-policy.ts` does that).
*/
export function posixNormalize(path: string): string {
if (path === '' || path === '/') return '/';
const isAbs = path.startsWith('/');
const segments = path.split('/');
const stack: string[] = [];
for (const seg of segments) {
if (seg === '' || seg === '.') continue;
if (seg === '..') {
if (stack.length > 0 && stack[stack.length - 1] !== '..') {
stack.pop();
} else if (!isAbs) {
stack.push('..');
}
// For absolute paths, `..` past root is silently dropped.
continue;
}
stack.push(seg);
}
const joined = stack.join('/');
if (isAbs) return '/' + joined;
return joined === '' ? '.' : joined;
}
/** Join path segments with POSIX semantics. */
export function posixJoin(...parts: string[]): string {
if (parts.length === 0) return '.';
const joined = parts.filter((p) => p !== '').join('/');
return posixNormalize(joined);
}
/** Return the directory portion of a path, or `'/'` for root. */
export function posixDirname(path: string): string {
const normalized = posixNormalize(path);
if (normalized === '/') return '/';
const lastSlash = normalized.lastIndexOf('/');
if (lastSlash === 0) return '/';
if (lastSlash === -1) return '.';
return normalized.slice(0, lastSlash);
}
/** Return the file/dir name (last component). */
export function posixBasename(path: string): string {
const normalized = posixNormalize(path);
if (normalized === '/') return '';
const lastSlash = normalized.lastIndexOf('/');
return lastSlash === -1 ? normalized : normalized.slice(lastSlash + 1);
}
/**
* Decode percent-escapes BEFORE security checks so attackers can't
* smuggle `..` via `%2e%2e`. Decodes UTF-8 sequences where possible;
* leaves invalid sequences untouched (the caller still rejects them).
*/
export function decodePercentEscapes(s: string): string {
if (!s.includes('%')) return s;
try {
return decodeURIComponent(s);
} catch {
// Invalid percent-encoding — leave as-is so policy rejects on the raw form.
return s;
}
}
/**
* Predicate: does `child` lie under `root` after both are normalized?
* Both must be absolute. `root` is treated as a directory boundary.
*/
export function isPathInside(child: string, root: string): boolean {
const c = posixNormalize(child);
const r = posixNormalize(root);
if (r === '/') return c.startsWith('/');
// Ensure boundary alignment so `/foo` is NOT inside `/foobar`.
return c === r || c.startsWith(r + '/');
}

View File

@@ -0,0 +1,77 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { z } from 'zod';
import { setupFileRig, type FileTestRig } from './helpers/rig.js';
import { CustomOpRejectedError, NotImplementedError } from '../../src/index.js';
// Module augmentation: register a typed custom op for the test.
declare module '../../src/index.js' {
interface CustomOpsMap {
'test.echo': { args: { message: string }; response: { echoed: string } };
'test.add': { args: { a: number; b: number }; response: { sum: number } };
}
}
describe('Custom ops — registry + Zod validation + typed I/O', () => {
let rig: FileTestRig;
const callLog: string[] = [];
beforeAll(async () => {
rig = await setupFileRig({
custom: {
'test.echo': {
args: z.object({ message: z.string().min(1).max(64) }),
response: z.object({ echoed: z.string() }),
handler: async (args, ctx) => {
callLog.push(`echo:${ctx.sender}:${args.message}`);
return { echoed: args.message.toUpperCase() };
},
},
'test.add': {
args: z.object({ a: z.number(), b: z.number() }),
response: z.object({ sum: z.number() }),
handler: async (args) => ({ sum: args.a + args.b }),
},
'test.bad-response': {
args: z.object({}),
response: z.object({ x: z.number() }),
// Returns wrong shape on purpose.
handler: async () => ({ y: 'not-a-number' }) as unknown as { x: number },
},
},
});
});
afterAll(async () => {
await rig.teardown();
});
test('typed echo round-trips through registered Zod schemas', async () => {
const result = await rig.fs.custom('test.echo', { message: 'hello' });
expect(result.echoed).toBe('HELLO');
expect(callLog).toContain('echo:alice:hello');
});
test('typed add', async () => {
const result = await rig.fs.custom('test.add', { a: 3, b: 4 });
expect(result.sum).toBe(7);
});
test('invalid args (Zod-rejected payload) → InvalidArgsError', async () => {
await expect(
// message: '' violates min(1) — TypeScript still allows it since string
rig.fs.custom('test.echo', { message: '' }),
).rejects.toThrow();
});
test('unknown custom op name → NotImplementedError', async () => {
await expect(
rig.fs.custom('test.unknown' as never, {} as never),
).rejects.toBeInstanceOf(NotImplementedError);
});
test('handler returns wrong shape → CustomOpRejectedError', async () => {
await expect(
rig.fs.custom('test.bad-response' as never, {} as never),
).rejects.toBeInstanceOf(CustomOpRejectedError);
});
});

View File

@@ -0,0 +1,210 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { sha256 } from '@noble/hashes/sha2.js';
import {
NotFoundError,
downloadDirectory,
createMemoryDirectory,
walk,
type DirectoryHandleLike,
type FileEntry,
} from '../../src/index.js';
import { setupFileRig, type FileTestRig } from './helpers/rig.js';
interface StoredFile {
bytes: Uint8Array;
contentType?: string;
sha256: string;
}
function bytesToHex(b: Uint8Array): string {
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
}
async function streamToBytes(s: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const reader = s.getReader();
const parts: Uint8Array[] = [];
let total = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value === undefined) continue;
parts.push(value);
total += value.byteLength;
}
reader.releaseLock();
const out = new Uint8Array(total);
let offset = 0;
for (const p of parts) {
out.set(p, offset);
offset += p.byteLength;
}
return out;
}
function streamFromBytes(bytes: Uint8Array): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes);
controller.close();
},
});
}
describe('downloadDirectory — bulk download from remote', () => {
let rig: FileTestRig;
const blobs = new Map<string, StoredFile>();
const dirs = new Set<string>();
beforeAll(async () => {
blobs.clear();
dirs.clear();
// Build remote tree:
// /src/
// ├── small.txt ('hello world\n', 12 bytes)
// ├── img.bin (50 KiB random)
// └── nested/
// ├── big.bin (400 KiB random)
// └── tiny.bin (3 bytes)
dirs.add('/');
dirs.add('/src');
dirs.add('/src/nested');
const small = new TextEncoder().encode('hello world\n');
const mid = new Uint8Array(50 * 1024); crypto.getRandomValues(mid);
const big = new Uint8Array(400 * 1024); crypto.getRandomValues(big);
const tiny = new Uint8Array([1, 2, 3]);
blobs.set('/src/small.txt', { bytes: small, sha256: bytesToHex(sha256(small)), contentType: 'text/plain' });
blobs.set('/src/img.bin', { bytes: mid, sha256: bytesToHex(sha256(mid)) });
blobs.set('/src/nested/big.bin', { bytes: big, sha256: bytesToHex(sha256(big)) });
blobs.set('/src/nested/tiny.bin', { bytes: tiny, sha256: bytesToHex(sha256(tiny)) });
rig = await setupFileRig({
list: async (ctx) => {
if (!dirs.has(ctx.path)) throw new NotFoundError(ctx.path);
const entries: FileEntry[] = [];
const dirPrefix = ctx.path === '/' ? '/' : ctx.path + '/';
// Subdirs
for (const d of dirs) {
if (d === ctx.path) continue;
if (!d.startsWith(dirPrefix)) continue;
const rest = d.slice(dirPrefix.length);
if (rest.includes('/')) continue;
entries.push({ name: rest, kind: 'dir', size: 0, mtime: 0, metadata: {} });
}
// Files
for (const [path, blob] of blobs) {
if (!path.startsWith(dirPrefix)) continue;
const rest = path.slice(dirPrefix.length);
if (rest.includes('/')) continue;
entries.push({
name: rest,
kind: 'file',
size: blob.bytes.byteLength,
mtime: 0,
...(blob.contentType !== undefined ? { contentType: blob.contentType } : {}),
metadata: {},
});
}
return { entries, hasMore: false };
},
read: async (ctx) => {
const blob = blobs.get(ctx.path);
if (blob === undefined) throw new NotFoundError(ctx.path);
if (blob.bytes.byteLength > 256 * 1024) {
return blob.contentType !== undefined
? {
kind: 'streams',
stream: streamFromBytes(blob.bytes),
size: blob.bytes.byteLength,
sha256: blob.sha256,
contentType: blob.contentType,
}
: {
kind: 'streams',
stream: streamFromBytes(blob.bytes),
size: blob.bytes.byteLength,
sha256: blob.sha256,
};
}
return blob.contentType !== undefined
? { kind: 'inline', bytes: blob.bytes, sha256: blob.sha256, contentType: blob.contentType }
: { kind: 'inline', bytes: blob.bytes, sha256: blob.sha256 };
},
});
});
afterAll(async () => {
await rig.teardown();
});
test('downloads entire tree, sha256 matches per file', async () => {
const local = createMemoryDirectory('local');
const handle = downloadDirectory(rig.fs, '/src', local);
const result = await handle.done();
expect(result.filesDone).toBe(4);
expect(result.bytesDone).toBe(12 + 50 * 1024 + 400 * 1024 + 3);
// Verify local tree contents
const downloadedFiles = new Map<string, Uint8Array>();
async function dump(dir: DirectoryHandleLike, prefix: string): Promise<void> {
for await (const [name, child] of dir.entries()) {
const path = prefix === '' ? name : `${prefix}/${name}`;
if (child.kind === 'directory') {
await dump(child as DirectoryHandleLike, path);
} else {
const file = await (child as { getFile: () => Promise<{ arrayBuffer: () => Promise<ArrayBuffer> }> }).getFile();
downloadedFiles.set(path, new Uint8Array(await file.arrayBuffer()));
}
}
}
await dump(local, '');
expect(downloadedFiles.size).toBe(4);
expect(downloadedFiles.has('small.txt')).toBe(true);
expect(downloadedFiles.has('img.bin')).toBe(true);
expect(downloadedFiles.has('nested/big.bin')).toBe(true);
expect(downloadedFiles.has('nested/tiny.bin')).toBe(true);
expect(bytesToHex(sha256(downloadedFiles.get('nested/big.bin')!))).toBe(
blobs.get('/src/nested/big.bin')!.sha256,
);
expect(bytesToHex(sha256(downloadedFiles.get('img.bin')!))).toBe(
blobs.get('/src/img.bin')!.sha256,
);
});
test('aggregated progress events fire monotonically', async () => {
const local = createMemoryDirectory('local');
const handle = downloadDirectory(rig.fs, '/src', local);
const progresses: { filesDone: number; bytesDone: number }[] = [];
(async () => {
for await (const ev of handle.events) {
if (ev.type === 'progress') {
progresses.push({ filesDone: ev.filesDone, bytesDone: ev.bytesDone });
}
}
})().catch(() => undefined);
await handle.done();
await new Promise((r) => setTimeout(r, 30));
for (let i = 1; i < progresses.length; i++) {
expect(progresses[i]!.filesDone).toBeGreaterThanOrEqual(progresses[i - 1]!.filesDone);
expect(progresses[i]!.bytesDone).toBeGreaterThanOrEqual(progresses[i - 1]!.bytesDone);
}
});
test('aborts via handle.abort()', async () => {
const local = createMemoryDirectory('local');
const handle = downloadDirectory(rig.fs, '/src', local);
setTimeout(() => void handle.abort('test-cancel'), 5);
await expect(handle.done()).rejects.toThrow();
});
test('walk + downloadDirectory are consistent', async () => {
const local = createMemoryDirectory('local');
const remoteFiles: string[] = [];
for await (const item of walk(rig.fs, '/src')) {
if (item.entry.kind === 'file') remoteFiles.push(item.relativePath);
}
const handle = downloadDirectory(rig.fs, '/src', local);
const result = await handle.done();
expect(result.filesDone).toBe(remoteFiles.length);
});
});

View File

@@ -0,0 +1,142 @@
import { createShade, type Shade } from '@shade/sdk';
import {
createPrekeyServer,
MemoryPrekeyStore,
PrekeyServerEvents,
} from '@shade/server';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import {
PendingRpcRegistry,
ShadeFileRpcChannel,
attachClientRouting,
attachFileHandler,
createClientStreamsBridge,
createFileClient,
createFileHandler,
createServerStreamsBridge,
type ClientStreamsBridge,
type FileClient,
type FileHandler,
type FileHandlerConfig,
type ServerStreamsBridge,
} from '../../../src/index.js';
const crypto = new SubtleCryptoProvider();
export interface FileTestRig {
alice: Shade;
bob: Shade;
fs: FileClient;
bobHandler: FileHandler;
/** Server-side streams bridge (Bob). */
bobStreamsBridge: ServerStreamsBridge;
/** Client-side streams bridge (Alice). */
aliceStreamsBridge: ClientStreamsBridge;
/** Tear everything down (kills servers, shuts down shades). */
teardown(): Promise<void>;
}
/**
* Setup options.
*
* Defaults to wiring streams-bridges on both sides so content I/O tests
* (`read-write-streams.test.ts`) work transparently. Pass `withStreams: false`
* to skip — useful for the legacy `std-ops` tests that don't need them.
*/
export interface SetupRigOptions {
withStreams?: boolean;
}
export async function setupFileRig(
bobConfig: Omit<FileHandlerConfig, 'streamsBridge'>,
options: SetupRigOptions = {},
): Promise<FileTestRig> {
const withStreams = options.withStreams ?? true;
// 1. Prekey server
const prekeyEvents = new PrekeyServerEvents();
const prekey = createPrekeyServer({
crypto,
store: new MemoryPrekeyStore(),
disableRateLimit: true,
events: prekeyEvents,
});
const prekeyServer = Bun.serve({ port: 0, fetch: prekey.fetch });
const prekeyUrl = `http://localhost:${prekeyServer.port}`;
// 2. Two Shades
const alice = await createShade({ prekeyServer: prekeyUrl, address: 'alice' });
const bob = await createShade({ prekeyServer: prekeyUrl, address: 'bob' });
// 3. BOTH sides need a transferRoute mounted because file-RPC is
// request/response — Bob's reply must reach Alice's HTTP endpoint
// just as Alice's request reached Bob's.
let bobBaseUrl = '';
let aliceBaseUrl = '';
alice.configureTransfers({
resolveBaseUrl: async (peer) => {
if (peer === 'bob') return bobBaseUrl;
throw new Error(`alice: unknown peer ${peer}`);
},
});
bob.configureTransfers({
resolveBaseUrl: async (peer) => {
if (peer === 'alice') return aliceBaseUrl;
throw new Error(`bob: unknown peer ${peer}`);
},
});
const bobApp = await bob.transferRoute();
const aliceApp = await alice.transferRoute();
const bobServer = Bun.serve({ port: 0, fetch: bobApp.fetch });
const aliceServer = Bun.serve({ port: 0, fetch: aliceApp.fetch });
bobBaseUrl = `http://localhost:${bobServer.port}`;
aliceBaseUrl = `http://localhost:${aliceServer.port}`;
// 4. Streams bridges (both sides) — required for content I/O > 256 KiB.
let bobStreamsBridge: ServerStreamsBridge | undefined;
let aliceStreamsBridge: ClientStreamsBridge | undefined;
if (withStreams) {
bobStreamsBridge = await createServerStreamsBridge(bob);
aliceStreamsBridge = await createClientStreamsBridge(alice);
}
// 5. Bob: file handler + channel
const bobChannel = new ShadeFileRpcChannel(bob);
const fullBobConfig: FileHandlerConfig = {
...bobConfig,
...(bobStreamsBridge !== undefined ? { streamsBridge: bobStreamsBridge } : {}),
};
const bobHandler = createFileHandler(bob, fullBobConfig);
attachFileHandler(bobChannel, bobHandler);
// 6. Alice: client + channel + pending registry
const aliceChannel = new ShadeFileRpcChannel(alice);
const alicePending = new PendingRpcRegistry();
attachClientRouting(aliceChannel, alicePending);
const fs = createFileClient(alice, aliceChannel, alicePending, 'bob', {
defaultTimeoutMs: 5000,
...(aliceStreamsBridge !== undefined ? { streamsBridge: aliceStreamsBridge } : {}),
});
return {
alice,
bob,
fs,
bobHandler,
bobStreamsBridge: bobStreamsBridge as ServerStreamsBridge,
aliceStreamsBridge: aliceStreamsBridge as ClientStreamsBridge,
async teardown() {
bobChannel.destroy();
aliceChannel.destroy();
bobHandler.destroy();
if (bobStreamsBridge !== undefined) await bobStreamsBridge.destroy();
if (aliceStreamsBridge !== undefined) await aliceStreamsBridge.destroy();
await alice.shutdown();
await bob.shutdown();
bobServer.stop();
aliceServer.stop();
prekeyServer.stop();
},
};
}

View File

@@ -0,0 +1,72 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { setupFileRig, type FileTestRig } from './helpers/rig.js';
import {
METRIC_OP_DURATION_MS,
METRIC_OP_TOTAL,
METRIC_RATE_LIMIT_REJECT_TOTAL,
type MetricSink,
type MetricTags,
type FileEntry,
} from '../../src/index.js';
interface MetricEvent {
name: string;
value: number;
tags: MetricTags;
}
describe('Metrics — onMetric on success', () => {
let rig: FileTestRig;
const events: MetricEvent[] = [];
const sink: MetricSink = (name, value, tags) => events.push({ name, value, tags });
beforeAll(async () => {
rig = await setupFileRig({
onMetric: sink,
list: async () => ({ entries: [], hasMore: false }),
});
});
afterAll(async () => { await rig.teardown(); });
test('emits op_total + op_duration_ms with result=ok on success', async () => {
events.length = 0;
await rig.fs.list('/');
const totals = events.filter((e) => e.name === METRIC_OP_TOTAL);
expect(totals.length).toBeGreaterThanOrEqual(1);
expect(totals[0]!.tags.result).toBe('ok');
expect(totals[0]!.tags.op).toBe('list');
const durations = events.filter((e) => e.name === METRIC_OP_DURATION_MS);
expect(durations.length).toBeGreaterThanOrEqual(1);
expect(durations[0]!.value).toBeGreaterThanOrEqual(0);
});
});
describe('Metrics — onMetric rate-limit reject', () => {
let rig: FileTestRig;
const events: MetricEvent[] = [];
const sink: MetricSink = (name, value, tags) => events.push({ name, value, tags });
beforeAll(async () => {
rig = await setupFileRig({
onMetric: sink,
rateLimits: { maxOpsPerMinutePerSender: 3 },
stat: async (_ctx) => {
const e: FileEntry = { name: 'x', kind: 'file', size: 1, mtime: 0, metadata: {} };
return e;
},
});
});
afterAll(async () => { await rig.teardown(); });
test('emits rate_limit_reject_total when capacity exhausted', async () => {
events.length = 0;
// Cap is 3; first 3 stats succeed, 4th rejected.
await rig.fs.stat('/x');
await rig.fs.stat('/x');
await rig.fs.stat('/x');
await expect(rig.fs.stat('/x')).rejects.toThrow();
const rejects = events.filter((e) => e.name === METRIC_RATE_LIMIT_REJECT_TOTAL);
expect(rejects.length).toBeGreaterThanOrEqual(1);
expect(rejects[0]!.tags.op).toBe('stat');
});
});

View File

@@ -0,0 +1,138 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { sha256 } from '@noble/hashes/sha2.js';
import { NotFoundError, type FileEntry } from '../../src/index.js';
import type { UserReadResult, UserWriteArgs, WriteResult } from '../../src/server/io-types.js';
import type { OpContext } from '../../src/server/handler-context.js';
import { setupFileRig, type FileTestRig } from './helpers/rig.js';
interface StoredBlob {
bytes: Uint8Array;
contentType?: string;
sha256: string;
mtime: number;
}
function bytesToHex(b: Uint8Array): string {
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
}
describe('Content I/O — inline read/write E2E', () => {
let rig: FileTestRig;
const blobs = new Map<string, StoredBlob>();
beforeAll(async () => {
blobs.clear();
rig = await setupFileRig({
read: async (ctx: OpContext<{ path: string }>): Promise<UserReadResult> => {
const blob = blobs.get(ctx.path);
if (blob === undefined) throw new NotFoundError(ctx.path);
return blob.contentType !== undefined
? { kind: 'inline', bytes: blob.bytes, sha256: blob.sha256, contentType: blob.contentType }
: { kind: 'inline', bytes: blob.bytes, sha256: blob.sha256 };
},
write: async (ctx: OpContext<UserWriteArgs>): Promise<WriteResult> => {
const args = ctx.args;
if (args.content.kind !== 'inline') {
throw new Error('expected inline content for this test');
}
if (blobs.has(args.path) && !args.overwrite) {
throw new Error('exists');
}
const stored: StoredBlob = {
bytes: args.content.bytes,
...(args.contentType !== undefined ? { contentType: args.contentType } : {}),
sha256: args.content.sha256,
mtime: Date.now(),
};
blobs.set(args.path, stored);
const entry: FileEntry = {
name: args.path.split('/').filter(Boolean).pop() ?? '',
kind: 'file',
size: args.content.size,
mtime: stored.mtime,
...(args.contentType !== undefined ? { contentType: args.contentType } : {}),
metadata: { sha256: args.content.sha256 },
};
return { entry };
},
});
});
afterAll(async () => {
await rig.teardown();
});
test('write 1 KiB inline → read it back, sha256 matches', async () => {
const data = new Uint8Array(1024);
for (let i = 0; i < data.length; i++) data[i] = (i * 7) & 0xff;
const expectedSha = bytesToHex(sha256(data));
const writeResult = await rig.fs.write('/small.bin', data, { contentType: 'application/octet-stream' });
expect(writeResult.entry.size).toBe(1024);
expect(writeResult.entry.metadata.sha256).toBe(expectedSha);
const readResult = await rig.fs.read('/small.bin');
expect(readResult.kind).toBe('inline');
if (readResult.kind === 'inline') {
expect(readResult.bytes.byteLength).toBe(1024);
expect(readResult.sha256).toBe(expectedSha);
expect(readResult.contentType).toBe('application/octet-stream');
expect(Array.from(readResult.bytes)).toEqual(Array.from(data));
}
});
test('write 100 KiB inline → read it back, sha256 matches', async () => {
const data = new Uint8Array(100 * 1024);
crypto.getRandomValues(data);
const expectedSha = bytesToHex(sha256(data));
await rig.fs.write('/big.bin', data);
const readResult = await rig.fs.read('/big.bin');
expect(readResult.kind).toBe('inline');
if (readResult.kind === 'inline') {
expect(readResult.bytes.byteLength).toBe(100 * 1024);
expect(readResult.sha256).toBe(expectedSha);
expect(Array.from(readResult.bytes)).toEqual(Array.from(data));
}
});
test('overwrite without flag → server-defined error; with flag → succeeds', async () => {
const a = new Uint8Array([1, 2, 3]);
const b = new Uint8Array([4, 5, 6]);
await rig.fs.write('/dup.bin', a);
await expect(rig.fs.write('/dup.bin', b)).rejects.toThrow();
await rig.fs.write('/dup.bin', b, { overwrite: true });
const out = await rig.fs.read('/dup.bin');
expect(out.kind).toBe('inline');
if (out.kind === 'inline') {
expect(Array.from(out.bytes)).toEqual([4, 5, 6]);
}
});
test('write Blob input → inferred contentType, round-trips', async () => {
const blob = new Blob([new Uint8Array([0xde, 0xad, 0xbe, 0xef])], { type: 'image/png' });
await rig.fs.write('/blobby.png', blob);
const out = await rig.fs.read('/blobby.png');
expect(out.kind).toBe('inline');
if (out.kind === 'inline') {
expect(out.contentType).toBe('image/png');
expect(Array.from(out.bytes)).toEqual([0xde, 0xad, 0xbe, 0xef]);
}
});
test('read non-existent → NotFoundError', async () => {
await expect(rig.fs.read('/missing.bin')).rejects.toThrow();
});
test('inline path also handles 256 KiB exactly (boundary)', async () => {
const data = new Uint8Array(256 * 1024);
crypto.getRandomValues(data);
const expectedSha = bytesToHex(sha256(data));
await rig.fs.write('/boundary.bin', data);
const out = await rig.fs.read('/boundary.bin');
expect(out.kind).toBe('inline');
if (out.kind === 'inline') {
expect(out.sha256).toBe(expectedSha);
}
});
});

View File

@@ -0,0 +1,175 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { sha256 } from '@noble/hashes/sha2.js';
import { NotFoundError, type FileEntry } from '../../src/index.js';
import { setupFileRig, type FileTestRig } from './helpers/rig.js';
interface StoredBlob {
bytes: Uint8Array;
contentType?: string;
sha256: string;
}
function bytesToHex(b: Uint8Array): string {
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
}
async function streamToBytes(s: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const reader = s.getReader();
const parts: Uint8Array[] = [];
let total = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value === undefined) continue;
parts.push(value);
total += value.byteLength;
}
reader.releaseLock();
const out = new Uint8Array(total);
let offset = 0;
for (const p of parts) {
out.set(p, offset);
offset += p.byteLength;
}
return out;
}
function streamFromBytes(bytes: Uint8Array): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes);
controller.close();
},
});
}
describe('Content I/O — streamed read/write E2E (>256 KiB)', () => {
let rig: FileTestRig;
const blobs = new Map<string, StoredBlob>();
beforeAll(async () => {
blobs.clear();
rig = await setupFileRig({
read: async (ctx) => {
const blob = blobs.get(ctx.path);
if (blob === undefined) throw new NotFoundError(ctx.path);
// Return as streams when blob ≥ 256 KiB.
if (blob.bytes.byteLength > 256 * 1024) {
return blob.contentType !== undefined
? {
kind: 'streams',
stream: streamFromBytes(blob.bytes),
size: blob.bytes.byteLength,
sha256: blob.sha256,
contentType: blob.contentType,
}
: {
kind: 'streams',
stream: streamFromBytes(blob.bytes),
size: blob.bytes.byteLength,
sha256: blob.sha256,
};
}
return blob.contentType !== undefined
? { kind: 'inline', bytes: blob.bytes, sha256: blob.sha256, contentType: blob.contentType }
: { kind: 'inline', bytes: blob.bytes, sha256: blob.sha256 };
},
write: async (ctx) => {
const args = ctx.args;
let bytes: Uint8Array;
let resolvedSha: string;
if (args.content.kind === 'inline') {
bytes = args.content.bytes;
resolvedSha = args.content.sha256;
} else {
bytes = await streamToBytes(args.content.stream);
resolvedSha = await args.content.sha256;
if (bytes.byteLength !== args.content.size) {
throw new Error(`stream produced ${bytes.byteLength} bytes; declared ${args.content.size}`);
}
}
const stored: StoredBlob = {
bytes,
...(args.contentType !== undefined ? { contentType: args.contentType } : {}),
sha256: resolvedSha,
};
blobs.set(args.path, stored);
const entry: FileEntry = {
name: args.path.split('/').filter(Boolean).pop() ?? '',
kind: 'file',
size: bytes.byteLength,
mtime: Date.now(),
...(args.contentType !== undefined ? { contentType: args.contentType } : {}),
metadata: { sha256: resolvedSha },
};
return { entry };
},
});
});
afterAll(async () => {
await rig.teardown();
});
test('write 1 MiB streamed → server sees streams content + sha256 matches', async () => {
const data = new Uint8Array(1024 * 1024);
crypto.getRandomValues(data);
const expectedSha = bytesToHex(sha256(data));
const writeResult = await rig.fs.write('/big1mb.bin', data);
expect(writeResult.entry.size).toBe(data.byteLength);
expect(writeResult.entry.metadata.sha256).toBe(expectedSha);
});
test('read 1 MiB streamed → client gets streams output, drains correctly', async () => {
const out = await rig.fs.read('/big1mb.bin');
expect(out.kind).toBe('streams');
if (out.kind === 'streams') {
expect(out.size).toBe(1024 * 1024);
const drained = await streamToBytes(out.stream);
expect(drained.byteLength).toBe(1024 * 1024);
expect(bytesToHex(sha256(drained))).toBe(out.sha256);
await out.done();
}
});
test('boundary 256 KiB + 1 → streams write + read round-trip', async () => {
const data = new Uint8Array(256 * 1024 + 1);
for (let i = 0; i < data.length; i++) data[i] = (i * 31) & 0xff;
const expectedSha = bytesToHex(sha256(data));
await rig.fs.write('/boundary-plus-one.bin', data);
const out = await rig.fs.read('/boundary-plus-one.bin');
expect(out.kind).toBe('streams');
if (out.kind === 'streams') {
expect(out.sha256).toBe(expectedSha);
const drained = await streamToBytes(out.stream);
expect(drained.byteLength).toBe(256 * 1024 + 1);
expect(Array.from(drained.slice(0, 4))).toEqual(Array.from(data.slice(0, 4)));
expect(Array.from(drained.slice(-4))).toEqual(Array.from(data.slice(-4)));
await out.done();
}
});
test('write streams via { stream, size } wrapper → ok', async () => {
const data = new Uint8Array(500 * 1024);
crypto.getRandomValues(data);
const expectedSha = bytesToHex(sha256(data));
const wrapped = { stream: streamFromBytes(data), size: data.byteLength, contentType: 'application/octet-stream' };
const result = await rig.fs.write('/wrapped.bin', wrapped);
expect(result.entry.size).toBe(data.byteLength);
expect(result.entry.metadata.sha256).toBe(expectedSha);
expect(result.entry.contentType).toBe('application/octet-stream');
});
test('write streams from Blob > 256 KiB → ok', async () => {
const data = new Uint8Array(400 * 1024);
crypto.getRandomValues(data);
const expectedSha = bytesToHex(sha256(data));
const blob = new Blob([data], { type: 'image/png' });
const result = await rig.fs.write('/blobby-big.png', blob);
expect(result.entry.size).toBe(data.byteLength);
expect(result.entry.metadata.sha256).toBe(expectedSha);
expect(result.entry.contentType).toBe('image/png');
});
});

View File

@@ -0,0 +1,100 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { createShade, type Shade } from '@shade/sdk';
import { createPrekeyServer, MemoryPrekeyStore, PrekeyServerEvents } from '@shade/server';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import type { FileEntry } from '../../src/index.js';
const crypto = new SubtleCryptoProvider();
/**
* End-to-end test of `Shade.files` — the high-level SDK entrypoint.
* Verifies that `shade.files.serve(...)` and `shade.files.client(peer)`
* compose correctly and share a single channel + bridges per Shade.
*/
describe('Shade.files namespace — end-to-end via SDK getter', () => {
let alice: Shade;
let bob: Shade;
let prekeyServer: { stop(): void };
let aliceServer: { stop(): void };
let bobServer: { stop(): void };
let stopBobFiles: (() => Promise<void>) | null = null;
beforeAll(async () => {
const prekey = createPrekeyServer({
crypto,
store: new MemoryPrekeyStore(),
disableRateLimit: true,
events: new PrekeyServerEvents(),
});
prekeyServer = Bun.serve({ port: 0, fetch: prekey.fetch });
const prekeyUrl = `http://localhost:${(prekeyServer as unknown as { port: number }).port}`;
alice = await createShade({ prekeyServer: prekeyUrl, address: 'alice' });
bob = await createShade({ prekeyServer: prekeyUrl, address: 'bob' });
let aliceUrl = '';
let bobUrl = '';
alice.configureTransfers({
resolveBaseUrl: async (peer) => {
if (peer === 'bob') return bobUrl;
throw new Error(`unknown peer: ${peer}`);
},
});
bob.configureTransfers({
resolveBaseUrl: async (peer) => {
if (peer === 'alice') return aliceUrl;
throw new Error(`unknown peer: ${peer}`);
},
});
const aliceApp = await alice.transferRoute();
const bobApp = await bob.transferRoute();
aliceServer = Bun.serve({ port: 0, fetch: aliceApp.fetch });
bobServer = Bun.serve({ port: 0, fetch: bobApp.fetch });
aliceUrl = `http://localhost:${(aliceServer as unknown as { port: number }).port}`;
bobUrl = `http://localhost:${(bobServer as unknown as { port: number }).port}`;
});
afterAll(async () => {
if (stopBobFiles !== null) await stopBobFiles();
await alice.files.destroy();
await bob.files.destroy();
await alice.shutdown();
await bob.shutdown();
aliceServer.stop();
bobServer.stop();
prekeyServer.stop();
});
test('shade.files getter is memoized', () => {
const a = bob.files;
const b = bob.files;
expect(a).toBe(b);
});
test('serve() + client() round-trip stat through the SDK', async () => {
stopBobFiles = await bob.files.serve({
stat: async (ctx) => {
const e: FileEntry = {
name: ctx.path.split('/').filter(Boolean).pop() ?? '',
kind: 'file',
size: 42,
mtime: 1234,
metadata: {},
};
return e;
},
});
const fs = await alice.files.client('bob');
const result = await fs.stat('/answer.txt');
expect(result.name).toBe('answer.txt');
expect(result.size).toBe(42);
expect(result.mtime).toBe(1234);
});
test('second serve() throws (one handler per Shade)', async () => {
await expect(
bob.files.serve({ stat: async () => ({ name: 'x', kind: 'file', size: 0, mtime: 0, metadata: {} }) }),
).rejects.toThrow(/handler is already registered/);
});
});

View File

@@ -0,0 +1,199 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import {
ConflictError,
IdempotencyConflictError,
NotFoundError,
PermissionDeniedError,
type FileEntry,
} from '../../src/index.js';
import { setupFileRig, type FileTestRig } from './helpers/rig.js';
describe('Standard ops — list/stat/mkdir/delete/move E2E', () => {
let rig: FileTestRig;
// Simple in-memory backing store on Bob.
const tree = new Map<string, FileEntry>();
beforeAll(async () => {
tree.clear();
tree.set('/', { name: '', kind: 'dir', size: 0, mtime: 0, metadata: {} });
tree.set('/foo', { name: 'foo', kind: 'dir', size: 0, mtime: 100, metadata: {} });
tree.set('/foo/bar.txt', {
name: 'bar.txt',
kind: 'file',
size: 12,
mtime: 200,
contentType: 'text/plain',
metadata: {},
});
tree.set('/foo/baz.txt', {
name: 'baz.txt',
kind: 'file',
size: 5,
mtime: 300,
metadata: {},
});
rig = await setupFileRig({
list: async (ctx) => {
const dir = ctx.path;
const entries: FileEntry[] = [];
for (const [path, entry] of tree) {
if (path === dir) continue;
if (!path.startsWith(dir === '/' ? '/' : dir + '/')) continue;
const rest = path.slice(dir === '/' ? 1 : dir.length + 1);
if (rest.includes('/')) continue;
entries.push(entry);
}
return { entries, hasMore: false };
},
stat: async (ctx) => {
const e = tree.get(ctx.path);
if (e === undefined) throw new NotFoundError(`${ctx.path} not found`);
return e;
},
mkdir: async (ctx) => {
if (tree.has(ctx.path)) throw new ConflictError(`${ctx.path} exists`);
const name = ctx.path.split('/').filter(Boolean).pop() ?? '';
const entry: FileEntry = {
name,
kind: 'dir',
size: 0,
mtime: Date.now(),
metadata: {},
};
tree.set(ctx.path, entry);
return { entry };
},
delete: async (ctx) => {
if (!tree.has(ctx.path)) throw new NotFoundError(ctx.path);
let count = 0;
if (ctx.args.recursive) {
for (const path of [...tree.keys()]) {
if (path === ctx.path || path.startsWith(ctx.path + '/')) {
tree.delete(path);
count++;
}
}
} else {
tree.delete(ctx.path);
count = 1;
}
return { deletedCount: count };
},
move: async (ctx) => {
const src = ctx.args.src;
const dst = ctx.args.dst;
const e = tree.get(src);
if (e === undefined) throw new NotFoundError(src);
if (tree.has(dst) && !ctx.args.overwrite) {
throw new ConflictError(`${dst} exists`);
}
const newName = dst.split('/').filter(Boolean).pop() ?? e.name;
tree.delete(src);
tree.set(dst, { ...e, name: newName });
return { entry: tree.get(dst)! };
},
});
});
afterAll(async () => {
await rig.teardown();
});
test('list /', async () => {
const page = await rig.fs.list('/');
expect(page.hasMore).toBe(false);
const names = page.entries.map((e) => e.name).sort();
expect(names).toEqual(['foo']);
});
test('list /foo', async () => {
const page = await rig.fs.list('/foo');
const names = page.entries.map((e) => e.name).sort();
expect(names).toEqual(['bar.txt', 'baz.txt']);
});
test('stat existing file', async () => {
const e = await rig.fs.stat('/foo/bar.txt');
expect(e.size).toBe(12);
expect(e.contentType).toBe('text/plain');
});
test('stat missing → NotFoundError', async () => {
let caught: unknown = null;
try {
await rig.fs.stat('/no/such/file');
} catch (err) {
caught = err;
}
expect(caught instanceof NotFoundError).toBe(true);
expect((caught as NotFoundError).payload.code).toBe('NOT_FOUND');
});
test('mkdir creates a new directory', async () => {
const result = await rig.fs.mkdir('/created');
expect(result.entry.kind).toBe('dir');
expect(tree.has('/created')).toBe(true);
});
test('mkdir on existing path → ConflictError', async () => {
await expect(rig.fs.mkdir('/foo')).rejects.toBeInstanceOf(ConflictError);
});
test('delete file', async () => {
tree.set('/temp.txt', { name: 'temp.txt', kind: 'file', size: 0, mtime: 0, metadata: {} });
const r = await rig.fs.delete('/temp.txt');
expect(r.deletedCount).toBe(1);
expect(tree.has('/temp.txt')).toBe(false);
});
test('delete missing → NotFoundError', async () => {
await expect(rig.fs.delete('/doesnt-exist')).rejects.toBeInstanceOf(NotFoundError);
});
test('move file', async () => {
tree.set('/x.txt', { name: 'x.txt', kind: 'file', size: 0, mtime: 0, metadata: {} });
const r = await rig.fs.move('/x.txt', '/y.txt');
expect(r.entry.name).toBe('y.txt');
expect(tree.has('/x.txt')).toBe(false);
expect(tree.has('/y.txt')).toBe(true);
});
test('move overwrite=false → ConflictError on collision', async () => {
tree.set('/a.txt', { name: 'a.txt', kind: 'file', size: 0, mtime: 0, metadata: {} });
tree.set('/b.txt', { name: 'b.txt', kind: 'file', size: 0, mtime: 0, metadata: {} });
await expect(rig.fs.move('/a.txt', '/b.txt')).rejects.toBeInstanceOf(ConflictError);
});
test('idempotent retry: same key + same args → cached response', async () => {
tree.set('/idem.txt', { name: 'idem.txt', kind: 'file', size: 0, mtime: 0, metadata: {} });
const key = 'IdemKey1AaBbCcDdEeFfGg'; // 22 chars
const r1 = await rig.fs.delete('/idem.txt', { idempotencyKey: key });
expect(r1.deletedCount).toBe(1);
// 2nd call with same key → same result without throwing NotFound
const r2 = await rig.fs.delete('/idem.txt', { idempotencyKey: key });
expect(r2.deletedCount).toBe(1);
});
test('idempotency conflict: same key + different args', async () => {
tree.set('/c1.txt', { name: 'c1.txt', kind: 'file', size: 0, mtime: 0, metadata: {} });
tree.set('/c2.txt', { name: 'c2.txt', kind: 'file', size: 0, mtime: 0, metadata: {} });
const key = 'ConflictKeyAaBbCcDdEeF'; // 22 chars
await rig.fs.delete('/c1.txt', { idempotencyKey: key });
await expect(
rig.fs.delete('/c2.txt', { idempotencyKey: key }),
).rejects.toBeInstanceOf(IdempotencyConflictError);
});
test('path validation: traversal rejected by server', async () => {
await expect(rig.fs.list('/foo')).resolves.toBeDefined();
// Client-side schema rejects traversal too — bypass via direct bad path:
await expect(rig.fs.stat('/foo/../etc')).rejects.toThrow();
});
// We don't test PermissionDenied here (no beforeOp gate configured),
// but the type is referenced to ensure it's exported properly.
test('PermissionDeniedError is exported', () => {
expect(PermissionDeniedError).toBeDefined();
});
});

View File

@@ -0,0 +1,123 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { sha256 } from '@noble/hashes/sha2.js';
import { ConflictError, NotFoundError } from '../../src/index.js';
import { setupFileRig, type FileTestRig } from './helpers/rig.js';
function bytesToHex(b: Uint8Array): string {
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
}
// Minimal valid PNG: 8-byte signature + IHDR + zero-byte IDAT + IEND. We
// use realistic magic bytes so the format-hardening check accepts it.
function tinyPng(): Uint8Array {
const sig = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const filler = new Uint8Array(20);
const out = new Uint8Array(sig.length + filler.length);
out.set(sig, 0);
out.set(filler, sig.length);
return out;
}
function tinyJpeg(): Uint8Array {
const head = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
const filler = new Uint8Array(12);
const out = new Uint8Array(head.length + filler.length);
out.set(head, 0);
out.set(filler, head.length);
return out;
}
function tinyWebp(): Uint8Array {
// 'RIFF' + sizeLE + 'WEBP' + 'VP8 ' (or 'VP8L' / 'VP8X')
const out = new Uint8Array(20);
out.set([0x52, 0x49, 0x46, 0x46], 0); // 'RIFF'
out.set([0x0c, 0x00, 0x00, 0x00], 4); // size little-endian (12 bytes follow)
out.set([0x57, 0x45, 0x42, 0x50], 8); // 'WEBP'
out.set([0x56, 0x50, 0x38, 0x20], 12); // 'VP8 '
return out;
}
describe('Content I/O — getThumbnail E2E with format hardening', () => {
let rig: FileTestRig;
// Map: path → (size → { bytes, format })
const thumbnails = new Map<string, Map<number, { bytes: Uint8Array; format: 'png' | 'webp' | 'jpeg' }>>();
beforeAll(async () => {
thumbnails.clear();
thumbnails.set(
'/photo.png',
new Map([
[64, { bytes: tinyPng(), format: 'png' }],
[128, { bytes: tinyPng(), format: 'png' }],
]),
);
thumbnails.set(
'/holiday.jpg',
new Map([[256, { bytes: tinyJpeg(), format: 'jpeg' }]]),
);
thumbnails.set(
'/icon.webp',
new Map([[64, { bytes: tinyWebp(), format: 'webp' }]]),
);
// Mismatched format: server returns PNG bytes but claims JPEG.
thumbnails.set('/lying.png', new Map([[64, { bytes: tinyPng(), format: 'jpeg' }]]));
rig = await setupFileRig({
getThumbnail: async (ctx) => {
const sizes = thumbnails.get(ctx.path);
if (sizes === undefined) throw new NotFoundError(ctx.path);
const entry = sizes.get(ctx.args.size);
if (entry === undefined) throw new NotFoundError(`${ctx.path}@${ctx.args.size}`);
return {
bytes: entry.bytes,
format: entry.format,
width: ctx.args.size,
height: ctx.args.size,
};
},
});
});
afterAll(async () => {
await rig.teardown();
});
test('PNG thumbnail round-trips with computed sha256', async () => {
const result = await rig.fs.getThumbnail('/photo.png', 64);
expect(result.format).toBe('png');
expect(result.width).toBe(64);
expect(result.height).toBe(64);
expect(result.sha256).toBe(bytesToHex(sha256(result.bytes)));
// Verify magic bytes survived
expect(Array.from(result.bytes.slice(0, 8))).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
});
test('JPEG thumbnail round-trips', async () => {
const result = await rig.fs.getThumbnail('/holiday.jpg', 256);
expect(result.format).toBe('jpeg');
expect(result.width).toBe(256);
expect(Array.from(result.bytes.slice(0, 3))).toEqual([0xff, 0xd8, 0xff]);
});
test('WebP thumbnail round-trips', async () => {
const result = await rig.fs.getThumbnail('/icon.webp', 64);
expect(result.format).toBe('webp');
expect(Array.from(result.bytes.slice(0, 4))).toEqual([0x52, 0x49, 0x46, 0x46]);
expect(Array.from(result.bytes.slice(8, 12))).toEqual([0x57, 0x45, 0x42, 0x50]);
});
test('PNG bytes claimed as JPEG → format-hardening rejects', async () => {
await expect(rig.fs.getThumbnail('/lying.png', 64)).rejects.toBeInstanceOf(ConflictError);
});
test('non-existent path → NotFoundError', async () => {
await expect(rig.fs.getThumbnail('/missing.png', 64)).rejects.toBeInstanceOf(NotFoundError);
});
test('different sizes return different thumbnails', async () => {
const small = await rig.fs.getThumbnail('/photo.png', 64);
const big = await rig.fs.getThumbnail('/photo.png', 128);
expect(small.width).toBe(64);
expect(big.width).toBe(128);
});
});

View File

@@ -0,0 +1,238 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { sha256 } from '@noble/hashes/sha2.js';
import {
ConflictError,
NotFoundError,
uploadDirectory,
createMemoryDirectory,
type FileEntry,
type BulkTransferEvent,
} from '../../src/index.js';
import { setupFileRig, type FileTestRig } from './helpers/rig.js';
interface StoredFile {
bytes: Uint8Array;
contentType?: string;
sha256: string;
}
function bytesToHex(b: Uint8Array): string {
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
}
async function streamToBytes(s: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const reader = s.getReader();
const parts: Uint8Array[] = [];
let total = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value === undefined) continue;
parts.push(value);
total += value.byteLength;
}
reader.releaseLock();
const out = new Uint8Array(total);
let offset = 0;
for (const p of parts) {
out.set(p, offset);
offset += p.byteLength;
}
return out;
}
describe('uploadDirectory — bulk upload to remote', () => {
let rig: FileTestRig;
const blobs = new Map<string, StoredFile>();
const dirs = new Set<string>(['/']);
beforeAll(async () => {
blobs.clear();
dirs.clear();
dirs.add('/');
rig = await setupFileRig({
mkdir: async (ctx) => {
const path = ctx.path;
if (dirs.has(path)) {
if (!ctx.args.recursive) throw new ConflictError('exists');
// Idempotent for recursive
}
// Recursive: add ancestors
if (ctx.args.recursive) {
const segments = path.split('/').filter(Boolean);
let acc = '';
for (const seg of segments) {
acc += '/' + seg;
dirs.add(acc);
}
} else {
dirs.add(path);
}
return {
entry: {
name: path.split('/').filter(Boolean).pop() ?? '',
kind: 'dir',
size: 0,
mtime: Date.now(),
metadata: {},
},
};
},
write: async (ctx) => {
const args = ctx.args;
let bytes: Uint8Array;
let storedSha: string;
if (args.content.kind === 'inline') {
bytes = args.content.bytes;
storedSha = args.content.sha256;
} else {
bytes = await streamToBytes(args.content.stream);
storedSha = await args.content.sha256;
}
if (blobs.has(args.path) && !args.overwrite) {
throw new ConflictError(`${args.path} exists`);
}
blobs.set(args.path, {
bytes,
...(args.contentType !== undefined ? { contentType: args.contentType } : {}),
sha256: storedSha,
});
const entry: FileEntry = {
name: args.path.split('/').filter(Boolean).pop() ?? '',
kind: 'file',
size: bytes.byteLength,
mtime: Date.now(),
...(args.contentType !== undefined ? { contentType: args.contentType } : {}),
metadata: { sha256: storedSha },
};
return { entry };
},
});
});
afterAll(async () => {
await rig.teardown();
});
test('uploads a small tree with mixed inline + streams', async () => {
const local = createMemoryDirectory('local');
const sub = local.addDir('sub');
const small = new Uint8Array(100); for (let i = 0; i < small.length; i++) small[i] = i & 0xff;
const big = new Uint8Array(400 * 1024); crypto.getRandomValues(big);
local.addFile('hello.txt', new TextEncoder().encode('hello world'), 'text/plain');
local.addFile('small.bin', small);
sub.addFile('big.bin', big, 'application/octet-stream');
const handle = uploadDirectory(rig.fs, local, '/upload-target');
const events: BulkTransferEvent[] = [];
(async () => {
for await (const ev of handle.events) events.push(ev);
})().catch(() => undefined);
const result = await handle.done();
expect(result.filesDone).toBe(3);
expect(result.bytesDone).toBe(11 + 100 + 400 * 1024);
// Verify remote tree
expect(blobs.has('/upload-target/hello.txt')).toBe(true);
expect(blobs.has('/upload-target/small.bin')).toBe(true);
expect(blobs.has('/upload-target/sub/big.bin')).toBe(true);
expect(dirs.has('/upload-target/sub')).toBe(true);
// Sha256 paritet for streamed file
expect(blobs.get('/upload-target/sub/big.bin')!.sha256).toBe(bytesToHex(sha256(big)));
expect(blobs.get('/upload-target/hello.txt')!.contentType).toBe('text/plain');
// Wait a tick for events to flush
await new Promise((r) => setTimeout(r, 30));
const planEvent = events.find((e) => e.type === 'plan');
expect(planEvent).toBeDefined();
if (planEvent && planEvent.type === 'plan') {
expect(planEvent.totalFiles).toBe(3);
expect(planEvent.totalBytes).toBe(11 + 100 + 400 * 1024);
}
const completes = events.filter((e) => e.type === 'complete');
expect(completes.length).toBe(1);
});
test('aggregated progress is monotonically non-decreasing', async () => {
const local = createMemoryDirectory('local');
for (let i = 0; i < 10; i++) {
local.addFile(`f${i}.bin`, new Uint8Array(50));
}
const handle = uploadDirectory(rig.fs, local, '/progress');
const progresses: { filesDone: number; bytesDone: number }[] = [];
(async () => {
for await (const ev of handle.events) {
if (ev.type === 'progress') {
progresses.push({ filesDone: ev.filesDone, bytesDone: ev.bytesDone });
}
}
})().catch(() => undefined);
await handle.done();
await new Promise((r) => setTimeout(r, 30));
for (let i = 1; i < progresses.length; i++) {
expect(progresses[i]!.filesDone).toBeGreaterThanOrEqual(progresses[i - 1]!.filesDone);
expect(progresses[i]!.bytesDone).toBeGreaterThanOrEqual(progresses[i - 1]!.bytesDone);
}
expect(progresses[progresses.length - 1]!.filesDone).toBe(10);
});
test('fail-fast: first error aborts the bulk', async () => {
const local = createMemoryDirectory('local');
for (let i = 0; i < 5; i++) {
local.addFile(`x${i}.bin`, new Uint8Array(10));
}
// Pre-create a conflicting file at /conflict/x0.bin so the first write fails.
blobs.set('/conflict/x0.bin', { bytes: new Uint8Array(0), sha256: 'x' });
const handle = uploadDirectory(rig.fs, local, '/conflict', { concurrency: 1 });
await expect(handle.done()).rejects.toThrow();
});
test('continueOnError: completes despite per-file errors', async () => {
const local = createMemoryDirectory('local');
for (let i = 0; i < 5; i++) {
local.addFile(`y${i}.bin`, new Uint8Array(10));
}
blobs.set('/cont/y2.bin', { bytes: new Uint8Array(0), sha256: 'x' });
const handle = uploadDirectory(rig.fs, local, '/cont', {
concurrency: 1,
continueOnError: true,
});
const errors: string[] = [];
(async () => {
for await (const ev of handle.events) {
if (ev.type === 'file-error') errors.push(ev.path);
}
})().catch(() => undefined);
const result = await handle.done();
await new Promise((r) => setTimeout(r, 30));
expect(errors).toEqual(['y2.bin']);
expect(result.filesDone).toBe(4);
});
test('concurrency cap respected', async () => {
const local = createMemoryDirectory('local');
for (let i = 0; i < 30; i++) {
local.addFile(`z${i}.bin`, new Uint8Array(10));
}
// Concurrency above MAX (16) should be clamped.
const handle = uploadDirectory(rig.fs, local, '/cap', { concurrency: 100 });
const result = await handle.done();
expect(result.filesDone).toBe(30);
});
test('aborts mid-flight via handle.abort()', async () => {
const local = createMemoryDirectory('local');
for (let i = 0; i < 50; i++) {
local.addFile(`q${i}.bin`, new Uint8Array(50 * 1024)); // 50 KiB each
}
const handle = uploadDirectory(rig.fs, local, '/abort');
setTimeout(() => void handle.abort('test-cancel'), 20);
await expect(handle.done()).rejects.toThrow();
});
});

View File

@@ -0,0 +1,126 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { walk, type FileEntry, NotFoundError } from '../../src/index.js';
import { setupFileRig, type FileTestRig } from './helpers/rig.js';
describe('walk — async-iterable depth-first directory traversal', () => {
let rig: FileTestRig;
// In-memory tree:
// /
// ├── a/
// │ ├── 1.txt
// │ └── b/
// │ └── 2.txt
// ├── c/
// │ └── 3.txt
// └── 4.txt
const tree = new Map<string, FileEntry>();
function setEntry(path: string, kind: 'file' | 'dir'): void {
const name = path === '/' ? '' : path.split('/').filter(Boolean).pop() ?? '';
tree.set(path, { name, kind, size: kind === 'file' ? 10 : 0, mtime: 0, metadata: {} });
}
beforeAll(async () => {
tree.clear();
setEntry('/', 'dir');
setEntry('/a', 'dir');
setEntry('/a/1.txt', 'file');
setEntry('/a/b', 'dir');
setEntry('/a/b/2.txt', 'file');
setEntry('/c', 'dir');
setEntry('/c/3.txt', 'file');
setEntry('/4.txt', 'file');
rig = await setupFileRig({
list: async (ctx) => {
if (!tree.has(ctx.path)) throw new NotFoundError(ctx.path);
const entries: FileEntry[] = [];
for (const [path, entry] of tree) {
if (path === ctx.path) continue;
if (!path.startsWith(ctx.path === '/' ? '/' : ctx.path + '/')) continue;
const rest = path.slice(ctx.path === '/' ? 1 : ctx.path.length + 1);
if (rest.includes('/')) continue;
entries.push(entry);
}
return { entries, hasMore: false };
},
});
});
afterAll(async () => {
await rig.teardown();
});
test('walks the entire tree depth-first', async () => {
const items: { path: string; depth: number }[] = [];
for await (const item of walk(rig.fs, '/')) {
items.push({ path: item.relativePath, depth: item.depth });
}
// Depth-first order: visit a, then descend into a/* (1.txt + a/b → a/b/2.txt), then c, c/3.txt, then 4.txt
expect(items.map((i) => i.path)).toEqual([
'a',
'a/1.txt',
'a/b',
'a/b/2.txt',
'c',
'c/3.txt',
'4.txt',
]);
// Depth values
expect(items.find((i) => i.path === 'a')?.depth).toBe(1);
expect(items.find((i) => i.path === 'a/1.txt')?.depth).toBe(2);
expect(items.find((i) => i.path === 'a/b/2.txt')?.depth).toBe(3);
});
test('respects maxDepth', async () => {
const items: string[] = [];
for await (const item of walk(rig.fs, '/', { maxDepth: 1 })) {
items.push(item.relativePath);
}
// Only direct children — no descent into /a or /c.
expect(items.sort()).toEqual(['4.txt', 'a', 'c']);
});
test('breaks cleanly when consumer stops iterating', async () => {
const items: string[] = [];
for await (const item of walk(rig.fs, '/')) {
items.push(item.relativePath);
if (items.length === 2) break;
}
expect(items).toEqual(['a', 'a/1.txt']);
});
test('filter callback skips entries (and excludes their subtree)', async () => {
const items: string[] = [];
for await (const item of walk(rig.fs, '/', {
filter: (entry, _rel) => entry.name !== 'a',
})) {
items.push(item.relativePath);
}
expect(items.sort()).toEqual(['4.txt', 'c', 'c/3.txt']);
});
test('aborts via signal mid-walk', async () => {
const ctrl = new AbortController();
const items: string[] = [];
setTimeout(() => ctrl.abort(), 5);
let threw = false;
try {
for await (const item of walk(rig.fs, '/', { signal: ctrl.signal })) {
items.push(item.relativePath);
await new Promise((r) => setTimeout(r, 10));
}
} catch {
threw = true;
}
expect(threw).toBe(true);
});
test('walking non-existent path → throws on first list', async () => {
await expect(async () => {
for await (const _item of walk(rig.fs, '/nope')) {
/* unreachable */
}
}).toThrow();
});
});

View File

@@ -0,0 +1,86 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { setupFileRig, type FileTestRig } from '../integration/helpers/rig.js';
import { FingerprintRequiredError, NotFoundError, type FileEntry } from '../../src/index.js';
describe('Fingerprint gate', () => {
let rig: FileTestRig;
const verifiedSet = new Set<string>();
beforeAll(async () => {
verifiedSet.clear();
rig = await setupFileRig({
requireFingerprintVerifiedFor: (ctx) => {
// Mutations require verification; reads are optional.
const op = ctx.op;
if (op === 'mkdir' || op === 'delete' || op === 'move' || op === 'write') return 'required';
if (op === 'list') return 'optional';
return 'optional';
},
isFingerprintVerified: (sender) => verifiedSet.has(sender),
stat: async (ctx) => {
const e: FileEntry = {
name: ctx.path.split('/').filter(Boolean).pop() ?? '',
kind: 'file', size: 1, mtime: 0, metadata: {},
};
return e;
},
mkdir: async (ctx) => {
const e: FileEntry = {
name: ctx.path.split('/').filter(Boolean).pop() ?? '',
kind: 'dir', size: 0, mtime: 0, metadata: {},
};
return { entry: e };
},
delete: async () => ({ deletedCount: 1 }),
list: async () => ({ entries: [], hasMore: false }),
});
});
afterAll(async () => {
await rig.teardown();
});
test('mutation without verification → FingerprintRequiredError', async () => {
verifiedSet.delete('alice');
await expect(rig.fs.mkdir('/locked')).rejects.toBeInstanceOf(FingerprintRequiredError);
await expect(rig.fs.delete('/locked')).rejects.toBeInstanceOf(FingerprintRequiredError);
});
test('mutation after marking peer verified → succeeds', async () => {
verifiedSet.add('alice');
const result = await rig.fs.mkdir('/verified-dir');
expect(result.entry.name).toBe('verified-dir');
});
test('optional ops (stat, list) work without verification', async () => {
verifiedSet.delete('alice');
const stat = await rig.fs.stat('/anything');
expect(stat.name).toBe('anything');
const list = await rig.fs.list('/anything');
expect(list.entries).toEqual([]);
});
});
describe('Fingerprint gate with reject policy', () => {
let rig: FileTestRig;
beforeAll(async () => {
rig = await setupFileRig({
requireFingerprintVerifiedFor: () => 'reject',
stat: async (ctx) => {
const e: FileEntry = {
name: 'x',
kind: 'file',
size: 1,
mtime: 0,
metadata: {},
};
return e;
},
});
});
afterAll(async () => { await rig.teardown(); });
test('all ops rejected outright', async () => {
await expect(rig.fs.stat('/x')).rejects.toBeInstanceOf(FingerprintRequiredError);
});
});

View File

@@ -0,0 +1,55 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { setupFileRig, type FileTestRig } from '../integration/helpers/rig.js';
import { FsRateLimitError, QuotaExceededError, type FileEntry } from '../../src/index.js';
describe('Op rate limit', () => {
let rig: FileTestRig;
let listCount = 0;
beforeAll(async () => {
listCount = 0;
rig = await setupFileRig({
rateLimits: { maxOpsPerMinutePerSender: 5 },
list: async () => {
listCount++;
return { entries: [], hasMore: false };
},
});
});
afterAll(async () => { await rig.teardown(); });
test('op rate-limit kicks in after capacity', async () => {
listCount = 0;
for (let i = 0; i < 5; i++) await rig.fs.list('/');
expect(listCount).toBe(5);
await expect(rig.fs.list('/')).rejects.toBeInstanceOf(FsRateLimitError);
});
});
describe('Byte quota', () => {
let rig: FileTestRig;
beforeAll(async () => {
rig = await setupFileRig({
rateLimits: {
// Plenty of ops, but tight byte cap for the quota test.
maxOpsPerMinutePerSender: 100,
maxBytesPerHourPerSender: 1024,
},
write: async (ctx) => {
const e: FileEntry = {
name: ctx.args.path.split('/').filter(Boolean).pop() ?? '',
kind: 'file',
size: ctx.args.content.kind === 'inline' ? ctx.args.content.bytes.byteLength : ctx.args.content.size,
mtime: 0,
metadata: {},
};
return { entry: e };
},
});
});
afterAll(async () => { await rig.teardown(); });
test('write 2 KiB inline → exceeds 1 KiB/hour cap', async () => {
const big = new Uint8Array(2048);
await expect(rig.fs.write('/big.bin', big)).rejects.toBeInstanceOf(QuotaExceededError);
});
});

View File

@@ -0,0 +1,119 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { setupFileRig, type FileTestRig } from '../integration/helpers/rig.js';
import { ConflictError, NotFoundError, type FileEntry } from '../../src/index.js';
/**
* Replay-window: the dispatcher rejects requests where `signedAt` is more
* than ±5 min from the server clock. Plus: idempotent retries on the same
* mutation key produce a single side-effect even with stale signedAt.
*/
describe('Replay window + idempotent retry', () => {
let rig: FileTestRig;
let writeCount = 0;
const blobs = new Map<string, Uint8Array>();
beforeAll(async () => {
rig = await setupFileRig({
mkdir: async (ctx) => {
if (blobs.has(ctx.path)) throw new ConflictError('exists');
blobs.set(ctx.path, new Uint8Array(0));
const e: FileEntry = {
name: ctx.path.split('/').filter(Boolean).pop() ?? '',
kind: 'dir', size: 0, mtime: Date.now(), metadata: {},
};
return { entry: e };
},
write: async (ctx) => {
writeCount++;
if (ctx.args.content.kind !== 'inline') throw new Error('inline expected');
blobs.set(ctx.args.path, ctx.args.content.bytes);
const e: FileEntry = {
name: ctx.args.path.split('/').filter(Boolean).pop() ?? '',
kind: 'file',
size: ctx.args.content.bytes.byteLength,
mtime: Date.now(),
metadata: { sha256: ctx.args.content.sha256 },
};
return { entry: e };
},
stat: async (ctx) => {
if (!blobs.has(ctx.path)) throw new NotFoundError(ctx.path);
return {
name: ctx.path.split('/').filter(Boolean).pop() ?? '',
kind: 'file', size: 0, mtime: 0, metadata: {},
};
},
});
});
afterAll(async () => {
await rig.teardown();
});
test('idempotent retry: same key + same args → single side-effect', async () => {
writeCount = 0;
// Idempotency keys are exactly 22 chars, base64url alphabet.
const key = 'replay_key_1234567890A';
const data = new Uint8Array([1, 2, 3]);
const r1 = await rig.fs.write('/replay.bin', data, { idempotencyKey: key });
const r2 = await rig.fs.write('/replay.bin', data, { idempotencyKey: key });
const r3 = await rig.fs.write('/replay.bin', data, { idempotencyKey: key });
expect(writeCount).toBe(1);
expect(r1.entry.size).toBe(3);
expect(r2.entry.size).toBe(3);
expect(r3.entry.size).toBe(3);
});
test('out-of-window signedAt → InvalidSignatureError (skew rejection)', async () => {
// Build a custom client that LIES about signedAt. We hand-craft an
// RpcRequest envelope and ship it via the underlying channel.
const {
ShadeFileRpcChannel,
PendingRpcRegistry,
attachClientRouting,
KIND_STAT_V1,
generateRequestId,
} = await import('../../src/index.js');
const aliceChannel = new ShadeFileRpcChannel(rig.alice);
const alicePending = new PendingRpcRegistry();
attachClientRouting(aliceChannel, alicePending);
const requestId = generateRequestId();
const stalePromise = alicePending.register<unknown>(requestId, { timeoutMs: 3000 });
await aliceChannel.send('bob', {
kind: KIND_STAT_V1,
id: requestId,
args: { path: '/replay.bin' },
sig: 'unsigned',
signedAt: Date.now() - 10 * 60 * 1000, // 10 min in the past — outside ±5 min window
});
await expect(stalePromise).rejects.toThrow(/replay window|signature/i);
aliceChannel.destroy();
});
test('signedAt far in the future → also rejected', async () => {
const {
ShadeFileRpcChannel,
PendingRpcRegistry,
attachClientRouting,
KIND_STAT_V1,
generateRequestId,
} = await import('../../src/index.js');
const aliceChannel = new ShadeFileRpcChannel(rig.alice);
const alicePending = new PendingRpcRegistry();
attachClientRouting(aliceChannel, alicePending);
const requestId = generateRequestId();
const promise = alicePending.register<unknown>(requestId, { timeoutMs: 3000 });
await aliceChannel.send('bob', {
kind: KIND_STAT_V1,
id: requestId,
args: { path: '/replay.bin' },
sig: 'unsigned',
signedAt: Date.now() + 10 * 60 * 1000,
});
await expect(promise).rejects.toThrow(/replay window|signature/i);
aliceChannel.destroy();
});
});

View File

@@ -0,0 +1,115 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { ed25519 } from '@noble/curves/ed25519.js';
import { setupFileRig, type FileTestRig } from '../integration/helpers/rig.js';
import {
bytesToBase64,
canonicalRpcBytes,
hashArgs,
InvalidSignatureError,
type FileEntry,
NotFoundError,
} from '../../src/index.js';
/**
* The dispatcher's `verifySender` callback gets the canonical bytes the
* client claims they signed. By plugging a real Ed25519 verify in tests,
* we can demonstrate that:
* - A valid sig over the canonical bytes is accepted.
* - Tampering ANY bound field (kind, args, signedAt, sender) breaks
* verification → InvalidSignatureError.
*/
describe('Tampered envelope — Ed25519 sig verification', () => {
let rig: FileTestRig;
// Generate a stable Ed25519 keypair for Alice. Bob will pin it.
const alicePriv = ed25519.utils.randomSecretKey();
const alicePub = ed25519.getPublicKey(alicePriv);
beforeAll(async () => {
rig = await setupFileRig({
verifySender: (sender, canonical, sigBase64) => {
// We only know Alice's key for this test. Bob's pub key would be
// looked up similarly in a real app.
if (sender !== 'alice') return false;
// Decode base64 sig
try {
const bin = atob(sigBase64);
const sigBytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) sigBytes[i] = bin.charCodeAt(i);
return ed25519.verify(sigBytes, canonical, alicePub);
} catch {
return false;
}
},
stat: async (ctx) => {
if (ctx.path !== '/exists.txt') throw new NotFoundError(ctx.path);
const e: FileEntry = { name: 'exists.txt', kind: 'file', size: 1, mtime: 0, metadata: {} };
return e;
},
});
// Re-create the client with a real signRequest hook.
// (Rig's default fs has signRequest=undefined; we replace it.)
const { createFileClient, ShadeFileRpcChannel, PendingRpcRegistry, attachClientRouting } = await import('../../src/index.js');
const aliceChannel = new ShadeFileRpcChannel(rig.alice);
const alicePending = new PendingRpcRegistry();
attachClientRouting(aliceChannel, alicePending);
rig.fs = createFileClient(rig.alice, aliceChannel, alicePending, 'bob', {
defaultTimeoutMs: 5000,
signRequest: async (canonical) => {
const sig = ed25519.sign(canonical, alicePriv);
return bytesToBase64(sig);
},
});
});
afterAll(async () => {
await rig.teardown();
});
test('valid signature → request succeeds', async () => {
const result = await rig.fs.stat('/exists.txt');
expect(result.name).toBe('exists.txt');
});
test('tampered args → InvalidSignatureError', async () => {
// Craft a request manually: sign over '/a' but ship '/b'.
const { createFileClient, ShadeFileRpcChannel, PendingRpcRegistry, attachClientRouting } = await import('../../src/index.js');
const aliceChannel = new ShadeFileRpcChannel(rig.alice);
const alicePending = new PendingRpcRegistry();
attachClientRouting(aliceChannel, alicePending);
const tamperedFs = createFileClient(rig.alice, aliceChannel, alicePending, 'bob', {
defaultTimeoutMs: 3000,
signRequest: async (_canonical) => {
// Sign over a DIFFERENT canonical (different argsHash), so the
// server's recomputation won't match.
const fake = canonicalRpcBytes({
address: 'alice',
signedAt: 0,
kind: 'shade.fs.list/v1',
id: 'AAAAAAAAAAAAAAAAAAAAAA',
argsHash: hashArgs({ tampered: true }),
});
const sig = ed25519.sign(fake, alicePriv);
return bytesToBase64(sig);
},
});
await expect(tamperedFs.stat('/exists.txt')).rejects.toBeInstanceOf(InvalidSignatureError);
});
test('valid signature from unknown signer → InvalidSignatureError', async () => {
const { createFileClient, ShadeFileRpcChannel, PendingRpcRegistry, attachClientRouting } = await import('../../src/index.js');
const aliceChannel = new ShadeFileRpcChannel(rig.alice);
const alicePending = new PendingRpcRegistry();
attachClientRouting(aliceChannel, alicePending);
const otherPriv = ed25519.utils.randomSecretKey();
const wrongFs = createFileClient(rig.alice, aliceChannel, alicePending, 'bob', {
defaultTimeoutMs: 3000,
signRequest: async (canonical) => {
const sig = ed25519.sign(canonical, otherPriv);
return bytesToBase64(sig);
},
});
await expect(wrongFs.stat('/exists.txt')).rejects.toBeInstanceOf(InvalidSignatureError);
});
});

View File

@@ -0,0 +1,135 @@
import { describe, test, expect } from 'bun:test';
import {
canonicalRpcBytes,
canonicalJsonStringify,
hashArgs,
bytesToHex,
bytesToBase64,
base64ToBytes,
} from '../../src/index.js';
describe('canonicalJsonStringify', () => {
test('sorts object keys', () => {
expect(canonicalJsonStringify({ b: 1, a: 2 })).toBe('{"a":2,"b":1}');
});
test('skips undefined values', () => {
expect(canonicalJsonStringify({ a: 1, b: undefined, c: 2 })).toBe('{"a":1,"c":2}');
});
test('preserves array order', () => {
expect(canonicalJsonStringify([3, 1, 2])).toBe('[3,1,2]');
});
test('recursive sorting', () => {
const a = canonicalJsonStringify({ outer: { y: 1, x: 2 } });
const b = canonicalJsonStringify({ outer: { x: 2, y: 1 } });
expect(a).toBe(b);
expect(a).toBe('{"outer":{"x":2,"y":1}}');
});
test('handles primitives', () => {
expect(canonicalJsonStringify(null)).toBe('null');
expect(canonicalJsonStringify(true)).toBe('true');
expect(canonicalJsonStringify(42)).toBe('42');
expect(canonicalJsonStringify('hi')).toBe('"hi"');
});
test('different argument orders hash identically', () => {
const h1 = hashArgs({ b: 1, a: 2 });
const h2 = hashArgs({ a: 2, b: 1 });
expect(bytesToHex(h1)).toBe(bytesToHex(h2));
});
test('different values hash differently', () => {
expect(bytesToHex(hashArgs({ a: 1 }))).not.toBe(bytesToHex(hashArgs({ a: 2 })));
});
});
describe('canonicalRpcBytes', () => {
test('deterministic for same input (known vector)', () => {
const args = {
address: 'alice',
signedAt: 1730000000000,
kind: 'shade.fs.list/v1',
id: 'AbCdEfGhIjKlMnOpQrStUv',
argsHash: new Uint8Array(32).fill(0xab),
};
const a = canonicalRpcBytes(args);
const b = canonicalRpcBytes(args);
expect(a).toEqual(b);
});
test('changes when address differs', () => {
const base = {
signedAt: 1,
kind: 'shade.fs.list/v1',
id: 'AbCdEfGhIjKlMnOpQrStUv',
argsHash: new Uint8Array(32),
};
const a = canonicalRpcBytes({ ...base, address: 'alice' });
const b = canonicalRpcBytes({ ...base, address: 'bob' });
expect(a).not.toEqual(b);
});
test('changes when signedAt differs', () => {
const base = {
address: 'alice',
kind: 'shade.fs.list/v1',
id: 'AbCdEfGhIjKlMnOpQrStUv',
argsHash: new Uint8Array(32),
};
expect(canonicalRpcBytes({ ...base, signedAt: 1 })).not.toEqual(
canonicalRpcBytes({ ...base, signedAt: 2 }),
);
});
test('changes when kind differs', () => {
const base = {
address: 'alice',
signedAt: 1,
id: 'AbCdEfGhIjKlMnOpQrStUv',
argsHash: new Uint8Array(32),
};
expect(canonicalRpcBytes({ ...base, kind: 'shade.fs.list/v1' })).not.toEqual(
canonicalRpcBytes({ ...base, kind: 'shade.fs.stat/v1' }),
);
});
test('changes when argsHash differs', () => {
const base = {
address: 'alice',
signedAt: 1,
kind: 'shade.fs.list/v1',
id: 'AbCdEfGhIjKlMnOpQrStUv',
};
expect(
canonicalRpcBytes({ ...base, argsHash: new Uint8Array(32) }),
).not.toEqual(
canonicalRpcBytes({ ...base, argsHash: new Uint8Array(32).fill(1) }),
);
});
});
describe('hashArgs', () => {
test('produces 32-byte digest', () => {
expect(hashArgs({ a: 1 }).length).toBe(32);
});
test('null input is fine', () => {
expect(hashArgs(null).length).toBe(32);
});
});
describe('bytesToHex / base64', () => {
test('hex roundtrip', () => {
const b = new Uint8Array([0x01, 0xab, 0xff, 0x00]);
expect(bytesToHex(b)).toBe('01abff00');
});
test('base64 roundtrip', () => {
const b = new Uint8Array([1, 2, 3, 4]);
const enc = bytesToBase64(b);
expect(base64ToBytes(enc)).toEqual(b);
});
});

View File

@@ -0,0 +1,90 @@
import { describe, test, expect } from 'bun:test';
import { runWithConcurrency } from '../../src/client/concurrency.js';
async function* range(n: number): AsyncIterable<number> {
for (let i = 0; i < n; i++) yield i;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe('runWithConcurrency', () => {
test('runs all items', async () => {
const seen: number[] = [];
await runWithConcurrency(range(10), async (i) => {
seen.push(i);
}, { concurrency: 4 });
expect(seen.sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
test('respects concurrency cap (never exceeds N inflight)', async () => {
let inflight = 0;
let peak = 0;
await runWithConcurrency(range(50), async () => {
inflight++;
peak = Math.max(peak, inflight);
await delay(5);
inflight--;
}, { concurrency: 4 });
expect(peak).toBeLessThanOrEqual(4);
expect(peak).toBeGreaterThanOrEqual(2);
});
test('throws on first error by default (fail-fast)', async () => {
let processed = 0;
await expect(
runWithConcurrency(range(20), async (i) => {
await delay(1);
if (i === 3) throw new Error('boom');
processed++;
}, { concurrency: 2 }),
).rejects.toThrow('boom');
// We don't process all 20; bounded by fail-fast.
expect(processed).toBeLessThan(20);
});
test('continueOnError reports each + drains', async () => {
const errors: number[] = [];
let processed = 0;
await runWithConcurrency(range(10), async (i) => {
await delay(1);
if (i % 3 === 0) throw new Error(`bad-${i}`);
processed++;
}, {
concurrency: 3,
continueOnError: true,
onError: (item) => errors.push(item),
});
expect(processed).toBe(6); // i = 1,2,4,5,7,8
expect(errors.sort((a, b) => a - b)).toEqual([0, 3, 6, 9]);
});
test('aborts via signal', async () => {
const ctrl = new AbortController();
let processed = 0;
setTimeout(() => ctrl.abort(), 20);
await expect(
runWithConcurrency(range(100), async () => {
await delay(5);
processed++;
}, { concurrency: 4, signal: ctrl.signal }),
).rejects.toThrow();
expect(processed).toBeLessThan(100);
});
test('concurrency=1 is sequential', async () => {
const order: number[] = [];
await runWithConcurrency(range(5), async (i) => {
order.push(i);
await delay(1);
}, { concurrency: 1 });
expect(order).toEqual([0, 1, 2, 3, 4]);
});
test('throws on concurrency < 1', () => {
expect(() =>
runWithConcurrency(range(0), async () => undefined, { concurrency: 0 }),
).toThrow('concurrency must be ≥ 1');
});
});

View File

@@ -0,0 +1,64 @@
import { describe, test, expect } from 'bun:test';
import * as fc from 'fast-check';
import {
generateRequestId,
generateIdempotencyKey,
base64UrlEncode,
base64UrlDecode,
RequestIdSchema,
} from '../../src/index.js';
describe('generateRequestId', () => {
test('produces 22-char base64url string', () => {
const id = generateRequestId();
expect(id.length).toBe(22);
expect(RequestIdSchema.safeParse(id).success).toBe(true);
});
test('1e5 generated IDs are all unique', () => {
const seen = new Set<string>();
for (let i = 0; i < 100_000; i++) {
const id = generateRequestId();
expect(seen.has(id)).toBe(false);
seen.add(id);
}
expect(seen.size).toBe(100_000);
});
test('generateIdempotencyKey returns the same shape', () => {
expect(generateIdempotencyKey().length).toBe(22);
});
});
describe('base64url encode/decode', () => {
test('roundtrip arbitrary bytes (property-based)', () => {
fc.assert(
fc.property(fc.uint8Array({ minLength: 0, maxLength: 64 }), (bytes) => {
const decoded = base64UrlDecode(base64UrlEncode(bytes));
expect(decoded).toEqual(bytes);
}),
{ numRuns: 500 },
);
});
test('produces URL-safe alphabet only', () => {
fc.assert(
fc.property(fc.uint8Array({ minLength: 1, maxLength: 64 }), (bytes) => {
const enc = base64UrlEncode(bytes);
expect(enc).not.toMatch(/[+/=]/);
}),
{ numRuns: 200 },
);
});
test('handles empty input', () => {
expect(base64UrlEncode(new Uint8Array(0))).toBe('');
expect(base64UrlDecode('')).toEqual(new Uint8Array(0));
});
test('decodes inputs without padding correctly', () => {
expect(base64UrlDecode('YQ')).toEqual(new Uint8Array([0x61]));
expect(base64UrlDecode('YWI')).toEqual(new Uint8Array([0x61, 0x62]));
expect(base64UrlDecode('YWJj')).toEqual(new Uint8Array([0x61, 0x62, 0x63]));
});
});

View File

@@ -0,0 +1,149 @@
import { describe, test, expect } from 'bun:test';
import {
encodeEnvelope,
looksLikeFileEnvelope,
tryParseEnvelope,
classify,
KIND_LIST_V1,
KIND_ERROR_V1,
KIND_CANCEL_V1,
responseKindOf,
} from '../../src/index.js';
import type { RpcRequest, RpcResponse, RpcError, RpcCancel } from '../../src/index.js';
const ID = 'AbCdEfGhIjKlMnOpQrStUv';
describe('looksLikeFileEnvelope', () => {
test('matches plaintext containing shade.fs', () => {
expect(looksLikeFileEnvelope('{"kind":"shade.fs.list/v1"}')).toBe(true);
});
test('rejects unrelated', () => {
expect(looksLikeFileEnvelope('hello world')).toBe(false);
expect(looksLikeFileEnvelope('{"kind":"shade.stream-init/v1"}')).toBe(false);
});
});
describe('tryParseEnvelope', () => {
test('returns null on non-JSON', () => {
expect(tryParseEnvelope('not json {{')).toBeNull();
});
test('returns null on JSON that does not match any envelope', () => {
expect(tryParseEnvelope('{"kind":"unknown"}')).toBeNull();
expect(tryParseEnvelope('{"foo":"bar"}')).toBeNull();
});
test('classifies request', () => {
const req: RpcRequest = {
kind: KIND_LIST_V1,
id: ID,
args: { path: '/' },
sig: 'abc',
signedAt: 1,
};
const c = tryParseEnvelope(encodeEnvelope(req));
expect(c?.kind).toBe('request');
});
test('classifies response', () => {
const resp: RpcResponse = {
kind: responseKindOf(KIND_LIST_V1),
id: ID,
result: { entries: [], hasMore: false },
};
const c = tryParseEnvelope(encodeEnvelope(resp));
expect(c?.kind).toBe('response');
});
test('classifies error', () => {
const err: RpcError = {
kind: KIND_ERROR_V1,
id: ID,
error: { code: 'NOT_FOUND', message: 'missing' },
};
const c = tryParseEnvelope(encodeEnvelope(err));
expect(c?.kind).toBe('error');
});
test('classifies cancel', () => {
const cancel: RpcCancel = {
kind: KIND_CANCEL_V1,
id: ID,
reason: 'user-cancel',
};
const c = tryParseEnvelope(encodeEnvelope(cancel));
expect(c?.kind).toBe('cancel');
});
test('rejects request with missing fields', () => {
expect(tryParseEnvelope(JSON.stringify({ kind: KIND_LIST_V1 }))).toBeNull();
expect(
tryParseEnvelope(
JSON.stringify({ kind: KIND_LIST_V1, id: ID, args: {}, sig: 'x' }),
),
).toBeNull(); // missing signedAt
});
test('rejects response shape with non-.response suffix and no signature', () => {
// Missing both `.response` suffix (so not a response) AND missing
// `sig`/`signedAt` (so not a valid request) → no schema matches.
expect(
tryParseEnvelope(
JSON.stringify({ kind: 'shade.fs.list/v1', id: ID, result: {} }),
),
).toBeNull();
});
test('rejects malformed id length', () => {
const req = {
kind: KIND_LIST_V1,
id: 'short',
args: {},
sig: 'x',
signedAt: 1,
};
expect(tryParseEnvelope(JSON.stringify(req))).toBeNull();
});
});
describe('classify on already-validated envelopes', () => {
test('correct discriminator on each branch', () => {
const req: RpcRequest = {
kind: KIND_LIST_V1, id: ID, args: {}, sig: 'x', signedAt: 1,
};
expect(classify(req).kind).toBe('request');
const resp: RpcResponse = {
kind: responseKindOf(KIND_LIST_V1), id: ID, result: {},
};
expect(classify(resp).kind).toBe('response');
const err: RpcError = {
kind: KIND_ERROR_V1, id: ID, error: { code: 'NOT_FOUND', message: '' },
};
expect(classify(err).kind).toBe('error');
const cancel: RpcCancel = { kind: KIND_CANCEL_V1, id: ID };
expect(classify(cancel).kind).toBe('cancel');
});
});
describe('encodeEnvelope', () => {
test('roundtrips request envelope', () => {
const req: RpcRequest = {
kind: KIND_LIST_V1,
id: ID,
args: { path: '/foo' },
idempotencyKey: 'IdemKeyAaBbCcDdEeFfGgH',
attempt: 1,
sig: 'sig',
signedAt: 1730000000000,
};
const c = tryParseEnvelope(encodeEnvelope(req));
expect(c?.kind).toBe('request');
if (c?.kind === 'request') {
expect(c.envelope.idempotencyKey).toBe('IdemKeyAaBbCcDdEeFfGgH');
expect(c.envelope.attempt).toBe(1);
}
});
});

View File

@@ -0,0 +1,108 @@
import { describe, test, expect } from 'bun:test';
import { IdempotencyCache, IdempotencyConflictError } from '../../src/index.js';
describe('IdempotencyCache.begin', () => {
test('first call returns fresh', () => {
const cache = new IdempotencyCache();
const result = cache.begin('alice', 'key1', { path: '/foo' });
expect(result.status).toBe('fresh');
});
test('replay returns cached response', () => {
const cache = new IdempotencyCache();
const a = cache.begin('alice', 'key1', { path: '/foo' });
if (a.status !== 'fresh') throw new Error('expected fresh');
a.commit({ ok: true });
const b = cache.begin('alice', 'key1', { path: '/foo' });
expect(b.status).toBe('replay');
if (b.status === 'replay') {
expect(b.response).toEqual({ ok: true });
}
});
test('argsHash mismatch throws IdempotencyConflictError', () => {
const cache = new IdempotencyCache();
const a = cache.begin('alice', 'key1', { path: '/foo' });
if (a.status !== 'fresh') throw new Error('expected fresh');
a.commit({ ok: true });
expect(() =>
cache.begin('alice', 'key1', { path: '/different' }),
).toThrow(IdempotencyConflictError);
});
test('inflight retry returns wait-promise', async () => {
const cache = new IdempotencyCache();
const a = cache.begin('alice', 'key1', { x: 1 });
if (a.status !== 'fresh') throw new Error('expected fresh');
const b = cache.begin('alice', 'key1', { x: 1 });
expect(b.status).toBe('wait');
if (b.status === 'wait') {
a.commit({ result: 42 });
const v = await b.promise;
expect(v).toEqual({ result: 42 });
}
});
test('different senders are isolated', () => {
const cache = new IdempotencyCache();
const a = cache.begin('alice', 'k', { x: 1 });
if (a.status !== 'fresh') throw new Error('expected fresh');
a.commit({ side: 'alice' });
const b = cache.begin('bob', 'k', { x: 1 });
expect(b.status).toBe('fresh');
});
test('abandon removes the entry so retries proceed fresh', () => {
const cache = new IdempotencyCache();
const a = cache.begin('alice', 'key', { p: 1 });
if (a.status !== 'fresh') throw new Error('expected fresh');
a.abandon();
const b = cache.begin('alice', 'key', { p: 1 });
expect(b.status).toBe('fresh');
});
});
describe('IdempotencyCache TTL + LRU', () => {
test('expired entries are evicted on next access', async () => {
const cache = new IdempotencyCache({ ttlMs: 5 });
const a = cache.begin('alice', 'k', { x: 1 });
if (a.status !== 'fresh') throw new Error('expected fresh');
a.commit('done');
await new Promise((r) => setTimeout(r, 15));
const b = cache.begin('alice', 'k', { x: 1 });
expect(b.status).toBe('fresh'); // expired → fresh again
});
test('LRU caps per-sender entries', () => {
const cache = new IdempotencyCache({ maxEntriesPerSender: 3 });
for (let i = 0; i < 5; i++) {
const r = cache.begin('alice', `key${i}`, { i });
if (r.status === 'fresh') r.commit(i);
}
expect(cache.size()).toBe(3); // first two evicted
});
});
describe('IdempotencyCache.prune', () => {
test('removes only TTL-expired entries', async () => {
// Two senders so begin() on one doesn't auto-evict the other's expired entry.
const cache = new IdempotencyCache({ ttlMs: 5 });
const a = cache.begin('alice', 'old', { x: 1 });
if (a.status === 'fresh') a.commit(1);
await new Promise((r) => setTimeout(r, 15));
const b = cache.begin('bob', 'new', { x: 2 });
if (b.status === 'fresh') b.commit(2);
const removed = cache.prune();
expect(removed).toBe(1); // alice/old expired; bob/new fresh
expect(cache.size()).toBe(1);
});
});

View File

@@ -0,0 +1,161 @@
import { describe, expect, test } from 'bun:test';
import { decideInline, INLINE_THRESHOLD } from '../../src/client/inline-threshold.js';
const KIB = 1024;
function streamOf(...chunks: Uint8Array[]): ReadableStream<Uint8Array> {
let i = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (i < chunks.length) {
controller.enqueue(chunks[i]!);
i++;
} else {
controller.close();
}
},
});
}
async function drainStream(s: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const reader = s.getReader();
const parts: Uint8Array[] = [];
let total = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value === undefined) continue;
parts.push(value);
total += value.byteLength;
}
reader.releaseLock();
const out = new Uint8Array(total);
let offset = 0;
for (const p of parts) {
out.set(p, offset);
offset += p.byteLength;
}
return out;
}
describe('decideInline (Uint8Array)', () => {
test('1 KiB → inline', async () => {
const bytes = new Uint8Array(KIB).fill(0xab);
const decision = await decideInline(bytes);
expect(decision.kind).toBe('inline');
if (decision.kind === 'inline') {
expect(decision.bytes.byteLength).toBe(KIB);
}
});
test('exactly 256 KiB → inline (boundary)', async () => {
const bytes = new Uint8Array(INLINE_THRESHOLD).fill(0xcd);
const decision = await decideInline(bytes);
expect(decision.kind).toBe('inline');
});
test('256 KiB + 1 → streams (boundary +1)', async () => {
const bytes = new Uint8Array(INLINE_THRESHOLD + 1).fill(0xef);
const decision = await decideInline(bytes);
expect(decision.kind).toBe('streams');
if (decision.kind === 'streams') {
expect(decision.size).toBe(INLINE_THRESHOLD + 1);
const drained = await drainStream(decision.stream);
expect(drained.byteLength).toBe(INLINE_THRESHOLD + 1);
}
});
});
describe('decideInline (Blob)', () => {
test('small Blob → inline + propagates contentType', async () => {
const blob = new Blob([new Uint8Array(100).fill(7)], { type: 'application/octet-stream' });
const decision = await decideInline(blob);
expect(decision.kind).toBe('inline');
if (decision.kind === 'inline') {
expect(decision.contentType).toBe('application/octet-stream');
expect(decision.bytes.byteLength).toBe(100);
}
});
test('large Blob → streams + propagates size + contentType', async () => {
const big = new Uint8Array(INLINE_THRESHOLD + KIB).fill(1);
const blob = new Blob([big], { type: 'image/png' });
const decision = await decideInline(blob);
expect(decision.kind).toBe('streams');
if (decision.kind === 'streams') {
expect(decision.size).toBe(big.byteLength);
expect(decision.contentType).toBe('image/png');
}
});
test('empty Blob.type → no contentType', async () => {
const blob = new Blob([new Uint8Array(10)]);
const decision = await decideInline(blob);
expect(decision.kind).toBe('inline');
if (decision.kind === 'inline') {
expect(decision.contentType).toBeUndefined();
}
});
});
describe('decideInline (ReadableStream — bare)', () => {
test('EOF before threshold → inline', async () => {
const stream = streamOf(new Uint8Array(100), new Uint8Array(200));
const decision = await decideInline(stream);
expect(decision.kind).toBe('inline');
if (decision.kind === 'inline') {
expect(decision.bytes.byteLength).toBe(300);
}
});
test('crosses threshold mid-chunk → streams + remainder available', async () => {
// 256 KiB + 1 byte across two chunks
const a = new Uint8Array(200 * KIB).fill(0x10);
const b = new Uint8Array(100 * KIB).fill(0x20);
const stream = streamOf(a, b);
const decision = await decideInline(stream);
expect(decision.kind).toBe('streams');
if (decision.kind === 'streams') {
const drained = await drainStream(decision.stream);
expect(drained.byteLength).toBe(a.byteLength + b.byteLength);
expect(drained[0]).toBe(0x10);
expect(drained[drained.length - 1]).toBe(0x20);
}
});
test('empty stream → inline (zero bytes)', async () => {
const stream = streamOf();
const decision = await decideInline(stream);
expect(decision.kind).toBe('inline');
if (decision.kind === 'inline') {
expect(decision.bytes.byteLength).toBe(0);
}
});
});
describe('decideInline ({ stream, size })', () => {
test('declared size ≤ threshold → inline', async () => {
const bytes = new Uint8Array(KIB).fill(9);
const decision = await decideInline({ stream: streamOf(bytes), size: KIB, contentType: 'text/plain' });
expect(decision.kind).toBe('inline');
if (decision.kind === 'inline') {
expect(decision.bytes.byteLength).toBe(KIB);
expect(decision.contentType).toBe('text/plain');
}
});
test('declared size > threshold → streams', async () => {
const big = new Uint8Array(500 * KIB).fill(2);
const decision = await decideInline({ stream: streamOf(big), size: big.byteLength });
expect(decision.kind).toBe('streams');
if (decision.kind === 'streams') {
expect(decision.size).toBe(big.byteLength);
}
});
});
describe('decideInline (errors)', () => {
test('unsupported input throws TypeError', async () => {
await expect(decideInline(42 as unknown as Uint8Array)).rejects.toThrow(TypeError);
});
});

View File

@@ -0,0 +1,128 @@
import { describe, test, expect } from 'bun:test';
import * as fc from 'fast-check';
import { validatePath } from '../../src/index.js';
describe('validatePath — happy path', () => {
test('accepts simple absolute paths', () => {
expect(validatePath('/foo')).toEqual({ ok: true, normalized: '/foo' });
expect(validatePath('/foo/bar/baz.txt')).toEqual({
ok: true,
normalized: '/foo/bar/baz.txt',
});
expect(validatePath('/')).toEqual({ ok: true, normalized: '/' });
});
test('normalizes redundant slashes and dots', () => {
expect(validatePath('//foo//bar/./baz/').normalized).toBe('/foo/bar/baz');
expect(validatePath('/./foo').normalized).toBe('/foo');
});
test('UTF-8 paths are accepted', () => {
expect(validatePath('/Документы/файл.txt').normalized).toBe('/Документы/файл.txt');
expect(validatePath('/絵文字 😀/foo').normalized).toBe('/絵文字 😀/foo');
});
});
describe('validatePath — security', () => {
test('rejects raw `..` segments', () => {
expect(validatePath('/../etc/passwd').ok).toBe(false);
expect(validatePath('/foo/../etc').ok).toBe(false);
expect(validatePath('/..').ok).toBe(false);
});
test('rejects percent-encoded `..`', () => {
expect(validatePath('/%2e%2e/etc').ok).toBe(false);
expect(validatePath('/foo/%2E%2E/etc').ok).toBe(false);
});
test('rejects forbidden control bytes', () => {
expect(validatePath('/foo\x00bar').ok).toBe(false);
expect(validatePath('/foo\r\nbar').ok).toBe(false);
expect(validatePath('/foo\x7f').ok).toBe(false);
expect(validatePath('/foo\x01').ok).toBe(false);
});
test('rejects backslashes (Windows-style)', () => {
expect(validatePath('/foo\\bar').ok).toBe(false);
});
test('rejects relative paths', () => {
expect(validatePath('foo').ok).toBe(false);
expect(validatePath('./foo').ok).toBe(false);
expect(validatePath('').ok).toBe(false);
});
test('rejects over-length paths', () => {
expect(validatePath('/' + 'a'.repeat(4096)).ok).toBe(false);
expect(validatePath('/foobar', { maxLength: 5 }).ok).toBe(false);
expect(validatePath('/abc', { maxLength: 5 }).ok).toBe(true);
});
});
describe('validatePath — rootScope', () => {
test('accepts paths inside scope', () => {
expect(
validatePath('/srv/data/foo', { rootScope: '/srv/data' }).ok,
).toBe(true);
expect(validatePath('/srv/data', { rootScope: '/srv/data' }).ok).toBe(true);
});
test('rejects paths outside scope', () => {
expect(validatePath('/etc/passwd', { rootScope: '/srv/data' }).ok).toBe(false);
expect(validatePath('/srv/dataX', { rootScope: '/srv/data' }).ok).toBe(false);
// Boundary check: /srv/database is NOT inside /srv/data
expect(validatePath('/srv/database/x', { rootScope: '/srv/data' }).ok).toBe(false);
});
});
describe('validatePath — extra hook', () => {
test('extra reject takes precedence', () => {
const result = validatePath('/secret/foo', {
extra: (p) => (p.includes('secret') ? 'reject' : 'allow'),
});
expect(result.ok).toBe(false);
});
test('extra allow is the default', () => {
expect(
validatePath('/foo', {
extra: () => 'allow',
}).ok,
).toBe(true);
});
});
describe('validatePath — property-based', () => {
test('any string with a forbidden control byte is rejected', () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 100 }),
fc.constantFrom('\x00', '\x07', '\x0a', '\x0d', '\x7f', '\\'),
(prefix, bad) => {
const p = `/${prefix}${bad}`;
expect(validatePath(p).ok).toBe(false);
},
),
{ numRuns: 200 },
);
});
test('any path inside rootScope normalizes within rootScope', () => {
fc.assert(
fc.property(
fc.array(fc.string({ minLength: 1, maxLength: 20 }).filter(
(s) => /^[A-Za-z0-9_-]+$/.test(s),
), { minLength: 1, maxLength: 5 }),
(segments) => {
const root = '/srv';
const path = `${root}/${segments.join('/')}`;
const r = validatePath(path, { rootScope: root });
if (r.ok) {
expect(r.normalized.startsWith(root)).toBe(true);
}
},
),
{ numRuns: 200 },
);
});
});

View File

@@ -0,0 +1,91 @@
import { describe, test, expect } from 'bun:test';
import {
FsRateLimitError,
QuotaExceededError,
RateLimiter,
} from '../../src/index.js';
describe('RateLimiter — op bucket', () => {
test('allows up to capacity then rejects', () => {
const rl = new RateLimiter({ maxOpsPerMinutePerSender: 5, opCost: { default: 1 } });
for (let i = 0; i < 5; i++) rl.acquire('alice', 'list');
expect(() => rl.acquire('alice', 'list')).toThrow(FsRateLimitError);
});
test('rejection includes retryAfterMs', () => {
const rl = new RateLimiter({ maxOpsPerMinutePerSender: 1, opCost: { default: 1 } });
rl.acquire('alice', 'list');
try {
rl.acquire('alice', 'list');
throw new Error('expected throw');
} catch (err) {
expect(err).toBeInstanceOf(FsRateLimitError);
const payload = (err as FsRateLimitError).payload;
expect(payload.retryAfterMs).toBeGreaterThan(0);
}
});
test('different op costs respected', () => {
const rl = new RateLimiter({
maxOpsPerMinutePerSender: 10,
opCost: { write: 5, default: 1 },
});
rl.acquire('alice', 'write'); // 10 - 5 = 5 left
rl.acquire('alice', 'write'); // 5 - 5 = 0 left
expect(() => rl.acquire('alice', 'write')).toThrow(FsRateLimitError);
});
test('per-sender isolation', () => {
const rl = new RateLimiter({ maxOpsPerMinutePerSender: 1 });
rl.acquire('alice', 'list');
rl.acquire('bob', 'list'); // bob's bucket independent
expect(() => rl.acquire('alice', 'list')).toThrow(FsRateLimitError);
expect(() => rl.acquire('bob', 'list')).toThrow(FsRateLimitError);
});
test('release returns tokens', () => {
const rl = new RateLimiter({ maxOpsPerMinutePerSender: 1 });
rl.acquire('alice', 'list');
rl.release('alice', 'list');
rl.acquire('alice', 'list'); // should succeed again
});
});
describe('RateLimiter — byte bucket', () => {
test('quota exceeded triggers QuotaExceededError', () => {
const rl = new RateLimiter({ maxBytesPerHourPerSender: 1000 });
rl.acquire('alice', 'write', 600);
rl.acquire('alice', 'write', 400);
expect(() => rl.acquire('alice', 'write', 1)).toThrow(QuotaExceededError);
});
test('reconcile returns over-reserved bytes', () => {
const rl = new RateLimiter({ maxBytesPerHourPerSender: 1000 });
rl.acquire('alice', 'write', 800);
rl.reconcile('alice', 800, 200); // we only used 200 of the 800 reserved
rl.acquire('alice', 'write', 600); // capacity now 400 + 600 = 1000, fits 600
});
test('release returns reserved bytes', () => {
const rl = new RateLimiter({ maxBytesPerHourPerSender: 100 });
rl.acquire('alice', 'read', 80);
rl.release('alice', 'read', 80);
rl.acquire('alice', 'read', 80);
});
});
describe('RateLimiter — refill', () => {
test('tokens refill over time', async () => {
const rl = new RateLimiter({
// 6000/min = 100/sec = 0.1/ms; in 50ms we refill 5
maxOpsPerMinutePerSender: 6000,
opCost: { default: 1 },
});
// Drain by 5
for (let i = 0; i < 5; i++) rl.acquire('alice', 'list');
const before = rl.snapshot('alice')!.ops;
await new Promise((r) => setTimeout(r, 50));
const after = rl.snapshot('alice')!.ops;
expect(after).toBeGreaterThan(before);
});
});

View File

@@ -0,0 +1,350 @@
import { describe, test, expect } from 'bun:test';
import {
PathSchema,
RequestIdSchema,
CursorSchema,
Sha256HexSchema,
FileEntrySchema,
FileKindSchema,
ListPageSchema,
ListArgsSchema,
StatArgsSchema,
MkdirArgsSchema,
DeleteArgsSchema,
MoveArgsSchema,
ReadArgsSchema,
ReadResultSchema,
WriteArgsSchema,
GetThumbnailArgsSchema,
GetThumbnailResultSchema,
CustomArgsSchema,
RpcRequestSchema,
RpcResponseSchema,
RpcErrorSchema,
RpcCancelSchema,
FileErrorCodeSchema,
FileErrorPayloadSchema,
} from '../../src/index.js';
describe('PathSchema', () => {
test('accepts absolute paths', () => {
expect(PathSchema.parse('/foo')).toBe('/foo');
expect(PathSchema.parse('/foo/bar/baz.txt')).toBe('/foo/bar/baz.txt');
expect(PathSchema.parse('/')).toBe('/');
});
test('rejects relative paths', () => {
expect(() => PathSchema.parse('foo')).toThrow();
expect(() => PathSchema.parse('./foo')).toThrow();
});
test('rejects NUL/CR/LF/DEL/backslash', () => {
expect(() => PathSchema.parse('/foo\x00bar')).toThrow();
expect(() => PathSchema.parse('/foo\r\n')).toThrow();
expect(() => PathSchema.parse('/foo\x7f')).toThrow();
expect(() => PathSchema.parse('/foo\\bar')).toThrow();
});
test('rejects empty + over-length', () => {
expect(() => PathSchema.parse('')).toThrow();
expect(() => PathSchema.parse('/' + 'a'.repeat(4096))).toThrow();
});
test('accepts UTF-8 in filenames', () => {
expect(PathSchema.parse('/Документы/файл.txt')).toBe('/Документы/файл.txt');
expect(PathSchema.parse('/πρόβλημα/αρχείο')).toBe('/πρόβλημα/αρχείο');
});
});
describe('RequestIdSchema', () => {
test('accepts 22-char base64url', () => {
expect(RequestIdSchema.parse('AbCdEfGhIjKlMnOpQrStUv')).toBe('AbCdEfGhIjKlMnOpQrStUv');
});
test('rejects wrong length', () => {
expect(() => RequestIdSchema.parse('AbCd')).toThrow();
expect(() => RequestIdSchema.parse('AbCdEfGhIjKlMnOpQrStUvWxYz')).toThrow();
});
test('rejects non-base64url chars', () => {
expect(() => RequestIdSchema.parse('AbCd/EfGh+IjKlMnOpQrST')).toThrow();
expect(() => RequestIdSchema.parse('AbCd=EfGhIjKlMnOpQrSTUV')).toThrow();
});
});
describe('CursorSchema', () => {
test('accepts up to 2048 chars', () => {
expect(CursorSchema.parse('a')).toBe('a');
expect(CursorSchema.parse('x'.repeat(2048)).length).toBe(2048);
});
test('rejects empty + over-length', () => {
expect(() => CursorSchema.parse('')).toThrow();
expect(() => CursorSchema.parse('x'.repeat(2049))).toThrow();
});
});
describe('Sha256HexSchema', () => {
test('accepts 64 hex chars', () => {
const h = '0'.repeat(64);
expect(Sha256HexSchema.parse(h)).toBe(h);
});
test('rejects wrong length / non-hex', () => {
expect(() => Sha256HexSchema.parse('0'.repeat(63))).toThrow();
expect(() => Sha256HexSchema.parse('0'.repeat(65))).toThrow();
expect(() => Sha256HexSchema.parse('g'.repeat(64))).toThrow();
expect(() => Sha256HexSchema.parse('A'.repeat(64))).toThrow(); // uppercase rejected
});
});
describe('FileKind / FileEntry', () => {
test('FileKind accepts file/dir', () => {
expect(FileKindSchema.parse('file')).toBe('file');
expect(FileKindSchema.parse('dir')).toBe('dir');
expect(() => FileKindSchema.parse('symlink')).toThrow();
});
test('FileEntry roundtrip', () => {
const e = {
name: 'foo.txt',
kind: 'file' as const,
size: 1024,
mtime: 1730000000000,
contentType: 'text/plain',
};
const parsed = FileEntrySchema.parse(e);
expect(parsed.name).toBe('foo.txt');
expect(parsed.metadata).toEqual({}); // default
});
test('FileEntry rejects path separators in name', () => {
expect(() =>
FileEntrySchema.parse({ name: 'foo/bar', kind: 'file', size: 0, mtime: 0 }),
).toThrow();
expect(() =>
FileEntrySchema.parse({ name: 'foo\\bar', kind: 'file', size: 0, mtime: 0 }),
).toThrow();
});
test('FileEntry rejects negative size', () => {
expect(() =>
FileEntrySchema.parse({ name: 'a', kind: 'file', size: -1, mtime: 0 }),
).toThrow();
});
test('FileEntry passes through metadata', () => {
const parsed = FileEntrySchema.parse({
name: 'mod.jar',
kind: 'file',
size: 100,
mtime: 0,
metadata: { modrinthId: 'abc', version: '1.0' },
});
expect(parsed.metadata).toEqual({ modrinthId: 'abc', version: '1.0' });
});
});
describe('ListPage', () => {
test('hasMore + nextCursor when more pages', () => {
const p = ListPageSchema.parse({
entries: [],
hasMore: true,
nextCursor: 'abc',
});
expect(p.hasMore).toBe(true);
expect(p.nextCursor).toBe('abc');
});
test('no nextCursor when hasMore false', () => {
const p = ListPageSchema.parse({ entries: [], hasMore: false });
expect(p.nextCursor).toBeUndefined();
});
});
describe('ListArgs', () => {
test('defaults pageSize to 100', () => {
const a = ListArgsSchema.parse({ path: '/foo' });
expect(a.pageSize).toBe(100);
});
test('rejects pageSize > 1000', () => {
expect(() => ListArgsSchema.parse({ path: '/foo', pageSize: 1001 })).toThrow();
});
test('rejects pageSize < 1', () => {
expect(() => ListArgsSchema.parse({ path: '/foo', pageSize: 0 })).toThrow();
});
});
describe('StatArgs', () => {
test('requires path', () => {
expect(() => StatArgsSchema.parse({})).toThrow();
expect(StatArgsSchema.parse({ path: '/foo' }).path).toBe('/foo');
});
});
describe('Mkdir/Delete/Move args', () => {
test('Mkdir defaults recursive=false', () => {
expect(MkdirArgsSchema.parse({ path: '/a' }).recursive).toBe(false);
});
test('Delete defaults recursive=false', () => {
expect(DeleteArgsSchema.parse({ path: '/a' }).recursive).toBe(false);
});
test('Move defaults overwrite=false', () => {
expect(
MoveArgsSchema.parse({ src: '/a', dst: '/b' }).overwrite,
).toBe(false);
});
});
describe('ReadArgs / ReadResult', () => {
test('Read accepts optional range', () => {
const a = ReadArgsSchema.parse({ path: '/a', range: { start: 0, end: 100 } });
expect(a.range).toEqual({ start: 0, end: 100 });
});
test('ReadResult inline', () => {
const r = ReadResultSchema.parse({
kind: 'inline',
bytesB64: 'YQ==',
size: 1,
sha256: 'a'.repeat(64),
});
expect(r.kind).toBe('inline');
});
test('ReadResult streams', () => {
const r = ReadResultSchema.parse({
kind: 'streams',
streamId: 'sid-123',
size: 1024,
sha256: 'b'.repeat(64),
});
expect(r.kind).toBe('streams');
});
test('ReadResult rejects unknown kind', () => {
expect(() =>
ReadResultSchema.parse({ kind: 'magic', bytesB64: 'YQ==' }),
).toThrow();
});
});
describe('WriteArgs', () => {
test('inline shape', () => {
const w = WriteArgsSchema.parse({
kind: 'inline',
path: '/foo',
bytesB64: 'YQ==',
});
expect(w.kind).toBe('inline');
if (w.kind === 'inline') expect(w.overwrite).toBe(false);
});
test('streams shape', () => {
const w = WriteArgsSchema.parse({
kind: 'streams',
path: '/foo',
size: 1000,
writeId: 'AbCdEfGhIjKlMnOpQrStUv',
});
if (w.kind === 'streams') expect(w.size).toBe(1000);
});
});
describe('GetThumbnailArgs / Result', () => {
test('size must be enum 64/128/256/512', () => {
expect(() => GetThumbnailArgsSchema.parse({ path: '/a', size: 100 })).toThrow();
expect(GetThumbnailArgsSchema.parse({ path: '/a', size: 64 }).size).toBe(64);
});
test('format defaults to png', () => {
const a = GetThumbnailArgsSchema.parse({ path: '/a', size: 128 });
expect(a.format).toBe('png');
});
test('Result requires width/height/sha256', () => {
const r = GetThumbnailResultSchema.parse({
bytesB64: 'YQ==',
format: 'png',
width: 128,
height: 128,
sha256: 'c'.repeat(64),
});
expect(r.width).toBe(128);
});
});
describe('CustomArgs', () => {
test('accepts dotted op names', () => {
expect(
CustomArgsSchema.parse({ name: 'dispatch.deploy-mod', payload: {} }).name,
).toBe('dispatch.deploy-mod');
});
test('rejects names with spaces or slashes', () => {
expect(() => CustomArgsSchema.parse({ name: 'foo bar', payload: {} })).toThrow();
expect(() => CustomArgsSchema.parse({ name: 'foo/bar', payload: {} })).toThrow();
});
});
describe('RPC envelopes', () => {
test('RpcRequest valid', () => {
const env = RpcRequestSchema.parse({
kind: 'shade.fs.list/v1',
id: 'AbCdEfGhIjKlMnOpQrStUv',
args: { path: '/' },
sig: 'sig',
signedAt: 1730000000000,
});
expect(env.kind).toBe('shade.fs.list/v1');
});
test('RpcRequest rejects malformed kind', () => {
expect(() =>
RpcRequestSchema.parse({
kind: 'shade.fs.list',
id: 'AbCdEfGhIjKlMnOpQrStUv',
args: {},
sig: 's',
signedAt: 1,
}),
).toThrow();
});
test('RpcResponse expects .response suffix', () => {
const env = RpcResponseSchema.parse({
kind: 'shade.fs.list/v1.response',
id: 'AbCdEfGhIjKlMnOpQrStUv',
result: { entries: [], hasMore: false },
});
expect(env.id.length).toBe(22);
});
test('RpcError fixed kind', () => {
const env = RpcErrorSchema.parse({
kind: 'shade.fs.error/v1',
id: 'AbCdEfGhIjKlMnOpQrStUv',
error: { code: 'NOT_FOUND', message: 'gone' },
});
expect(env.error.code).toBe('NOT_FOUND');
});
test('RpcCancel fixed kind', () => {
const env = RpcCancelSchema.parse({
kind: 'shade.fs.cancel/v1',
id: 'AbCdEfGhIjKlMnOpQrStUv',
reason: 'user-cancel',
});
expect(env.reason).toBe('user-cancel');
});
});
describe('FileError envelope', () => {
test('all known codes parse', () => {
const codes: ReadonlyArray<unknown> = [
'NOT_FOUND',
'PERMISSION_DENIED',
'CONFLICT',
'QUOTA_EXCEEDED',
'RATE_LIMIT',
'PATH_VALIDATION',
'FINGERPRINT_REQUIRED',
'OPERATION_TIMEOUT',
'IDEMPOTENCY_CONFLICT',
'CANCELLED',
'INTERNAL',
'NOT_IMPLEMENTED',
'CUSTOM_OP_REJECTED',
'INVALID_SIGNATURE',
'INVALID_ARGS',
];
for (const c of codes) expect(FileErrorCodeSchema.parse(c)).toBe(c);
});
test('rejects unknown code', () => {
expect(() => FileErrorCodeSchema.parse('NOT_REAL')).toThrow();
});
test('FileErrorPayload includes optional fields', () => {
const p = FileErrorPayloadSchema.parse({
code: 'RATE_LIMIT',
message: 'too fast',
retryAfterMs: 1000,
});
expect(p.retryAfterMs).toBe(1000);
});
});

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react"
},
"include": ["src"]
}