Skip to main content

FilesSource

FilesSource

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)
On this page