The data-source interface behind the file browser - the CHAPI /v2/files adapter, the in-memory source, custom sources, typed errors and the read-only rules
Tags: files files-source chapi network-drive adapter errors read-only
The file browser components never call fetch. They take a FilesSource, from @classic-homes/files-core (re-exported by @classic-homes/files-svelte):
export type FileEntryType = 'file' | 'dir' | 'link';
export interface FileEntry {
name: string;
path: string; // root-relative, '/' separated, '' = the root
type: FileEntryType; // 'link' = symlink/junction: shown, never opened
size: number; // bytes; 0 for dirs
modified: string; // ISO 8601
etag?: string; // already quoted; kept for If-Match on future writes
hidden: boolean;
}
export interface FileRoot {
id: string;
label: string;
description?: string | null;
canWrite: boolean;
}
export interface FileListPage {
entries: FileEntry[];
total?: number;
nextCursor?: string | null;
}
export interface FilesSource {
listRoots(signal?: AbortSignal): Promise<FileRoot[]>;
list(rootId: string, path: string, opts?: ListOptions): Promise<FileListPage>;
info(rootId: string, path: string, signal?: AbortSignal): Promise<FileEntry>;
/** Bytes for preview or download. Must send auth, so it returns a Blob, not a URL. */
read(
rootId: string,
path: string,
opts?: { download?: boolean; signal?: AbortSignal }
): Promise<Blob>;
// Optional
/** A short-lived link that downloads without credentials (streamed to disk, no Blob). */
downloadUrl?(rootId: string, path: string, signal?: AbortSignal): Promise<DownloadLink>;
search?(rootId: string, query: string, opts?: SearchOptions): Promise<FileListPage>;
upload?(rootId: string, path: string, file: Blob, opts?: UploadOptions): Promise<FileEntry>;
mkdir?(rootId: string, path: string, signal?: AbortSignal): Promise<FileEntry>;
move?(rootId: string, from: string, to: string, opts?: WriteOptions): Promise<FileEntry>;
remove?(rootId: string, path: string, opts?: WriteOptions): Promise<void>;
}list() returns directories first, then names case-insensitively, as CHAPI does. Your own sources must keep directories first: FileTree stops paging at the first file. Pass limit (floored and clamped to 1–1000; a non-finite value is left to the server default, 200) and cursor for paging, includeHidden for hidden files.
The CHAPI adapter
createChapiFilesSource() talks to CHAPI's /v2/files API, which fronts the on-prem SMB gateway. It sends the app's bearer token on every request and unwraps the { data, meta } envelope.
import { createChapiFilesSource } from '@classic-homes/files-core';
import { getAccessToken, getSessionToken, refreshSession } from '@classic-homes/auth/core';
export const files = createChapiFilesSource({
baseUrl: 'https://api.classichomes.com', // dev: https://dev.api.classichomes.com
getAccessToken,
getHeaders: () => {
const session = getSessionToken();
return session ? { 'X-Session-Token': session } : undefined;
},
// Refresh and retry once on a 401; otherwise the request fails as `unauthorized`
onUnauthorized: async () => (await refreshSession()) === 'refreshed',
});| Option | Type | Description |
|---|---|---|
baseUrl |
string |
CHAPI origin. A trailing /v1 or /v2 is ignored |
getAccessToken |
() => string | null | Promise<…> |
Bearer token, read on every request |
getHeaders |
() => Record<string, string> | … |
Extra headers per request |
onUnauthorized |
() => boolean | Promise<boolean> |
Called on 401; return true after a refresh to retry once. Concurrent 401s share one call |
fetch |
typeof fetch |
Custom fetch (tests, SSR) |
timeoutMs |
number |
Wait for response headers; default 30 s. Bodies take as long as they need |
| Method | Request |
|---|---|
listRoots() |
GET /v2/files/roots (only roots the caller may read; inactive ones are dropped) |
list() |
GET /v2/files/roots/{rootId}/entries?path=&limit=&cursor=&includeHidden= |
info() |
GET /v2/files/roots/{rootId}/info?path= |
read() |
GET /v2/files/roots/{rootId}/content?path=&download=true|false |
downloadUrl() |
POST /v2/files/roots/{rootId}/download-url with { path }: a signed link (60 s) to /v2/files/download?token= |
path is always a URL-encoded query parameter, root-relative and / separated. The helpers never build .. (and normalizePath rejects it); treat the path values the server returns as the truth and join with joinPath.
Read-only
canWrite is access === 'write' and readOnly === false (CHAPI sends readOnly on every root to every caller; null, when the service is unreachable, means no writes). Admins see access: 'write' on every root, but the Land Development pilot is read-only at the service, so canWrite is false. Never decide from access alone. The adapter has no write methods: CHAPI has no write endpoints yet. When they arrive, the write slots on FileBrowser light up for roots that allow it, with If-Match carrying the entry's etag.
The gateway always serves HTML, SVG, XML and JavaScript as an attachment under a sandbox CSP; the components never render them inline either.
Errors
Sources throw FilesError { code, status, message, retryAfter?, reason? }. The CHAPI adapter maps the HTTP status and error.details.reason:
| HTTP | reason |
code |
|---|---|---|
| 400 | invalid_path, not_a_directory, is_directory, invalid_cursor |
the same (anything else: invalid_path) |
| 400 | invalid_query, invalid_limit (CHAPI's own validation) |
invalid_request |
| 401 | - | unauthorized |
| 403 | link_not_followed / access_denied |
the same |
| 404 | not_found, or a root you can't read |
not_found |
| 423 | locked |
locked, with retryAfter seconds |
| 503 | busy, unavailable, not configured |
unavailable, with retryAfter seconds |
| 504 / 502 | timeout / file_server_error |
timeout / file_server_error |
| - | fetch failed | network |
A caller's abort rejects with a DOMException named AbortError (check isAbortError(err)), never a FilesError, whatever reason the signal was aborted with. A malformed reply (missing or non-array data, non-object info data) is a file_server_error with the HTTP status; an entry without path gets one from the folder it was listed in. describeFilesError(err) returns the user-facing { title, description, action } the components show.
In-memory source
For demos, tests and stories. It pages with cursors, hides hidden files, lists links but refuses to follow them, and throws the same errors:
import { createMemoryFilesSource, FilesError } from '@classic-homes/files-core';
const source = createMemoryFilesSource({
latency: 150,
roots: [
{
id: 'land-dev',
label: 'Land Development',
canWrite: false,
files: [
{
path: '105 BREWERY/Plans/Site spec.pdf',
content: () => fetch('/site-spec.pdf').then((r) => r.blob()),
size: 56_120,
},
{ path: '105 BREWERY/Thumbs.db', hidden: true },
{ path: 'Shortcut to Accounting', type: 'link' },
],
},
],
// Fail any request on demand
intercept: ({ op }) =>
op === 'read' ? new FilesError('locked', 'Locked', { status: 423 }) : undefined,
});Folders are created from the file paths; add { path, type: 'dir' } for an empty one. search: true adds a whole-root search().
A custom source
Implement the four required methods. Throw FilesError so the UI shows the right state, honour the AbortSignal, and return entries with root-relative paths:
import { FilesError, type FilesSource } from '@classic-homes/files-core';
export function sharePointSource(client: MyClient): FilesSource {
return {
listRoots: async () =>
(await client.sites()).map((s) => ({ id: s.id, label: s.name, canWrite: false })),
async list(rootId, path, opts) {
const page = await client.children(rootId, path, {
cursor: opts?.cursor,
signal: opts?.signal,
});
return { entries: page.items.map(toEntry), total: page.count, nextCursor: page.next };
},
info: async (rootId, path, signal) => toEntry(await client.item(rootId, path, { signal })),
async read(rootId, path, opts) {
const res = await client.download(rootId, path, { signal: opts?.signal });
if (res.status === 423) throw new FilesError('locked', 'Locked', { status: 423 });
return res.blob();
},
};
}Helpers
| Helper | Does |
|---|---|
joinPath(...parts) |
joinPath('Lots', '14/Plans') → 'Lots/14/Plans' |
parentPath(path), baseName(path) |
'a/b/c' → 'a/b', 'c' |
normalizePath(path) |
Strips slashes and .; throws invalid_path for .. |
pathSegments(path), displayPath(label, path) |
Breadcrumb segments; Land Development / a / b |
formatBytes(n, { locale }) |
1536 → 1.5 KB (binary units) |
formatRelativeTime(iso), formatDateTime(iso) |
3 days ago; locale date and time |
kindForName(name), kindForEntry(entry) |
'pdf', 'word', 'excel', 'csv', 'cad', 'image'… |
previewRoute(entry, formats) |
Document viewer, image, or none |
sortEntries, filterEntries, compareEntries |
Client sort (folders first), name filter, server order |
writeCapabilities(source, root) |
Which write actions to show (all false while read-only) |
saveBlob(blob, name) |
Save a Blob as a download |
openDownloadLink(url) |
Start a download from a downloadUrl() link (no referrer) |