Storage-agnostic file/directory tree browser. Plug a Store (R2, S3, HTTP-proxied, in-memory, …) into a React component and get a directory listing + file viewer (markdown, parquet, CSV, JSON, notebooks, code, images, video, audio, zip).
Live demo: see
site/(Vite app over MockStore + an HttpStore worker-proxy bound to R2). Active consumers:ctbk.dev,nj-crashes.com.
pnpm add @rdub/file-treeinterface Store {
list(prefix, opts?): Promise<{ entries: Entry[]; cursor?: string }>
get(path, range?): Promise<{ bytes: Uint8Array; totalSize?: number; contentType?: string }>
capabilities?: { range: boolean }
getUrl?(path): string // sync, public/static URL
getDownloadUrl?(path, opts?: { expiresIn? }): Promise<string> // async, signed/dynamic URL
getZipEntries?(path): Promise<ZipEntriesResult> // server-side zip
getZipEntry?(path, entry, opts?): Promise<GetResult> // shortcuts
}list + get are required; the rest are optional capabilities the UI uses when present (download anchor, server-accelerated zip preview, etc.).
Worker (worker/src/index.ts):
import { R2Store } from '@rdub/file-tree/stores/r2'
import { createHandlers } from '@rdub/file-tree/server'
interface Env { R2: R2Bucket }
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const store = R2Store(env.R2, {
prefixes: ['raw/'],
publicBaseUrl: 'https://data.example.com', // see Downloads section
})
const handlers = createHandlers(store, { basePath: '/v1/files' })
return (await handlers.handle(req)) ?? new Response('not found', { status: 404 })
},
}React app:
import { FileTree } from '@rdub/file-tree/react'
import { HttpStore } from '@rdub/file-tree/stores/http'
const store = HttpStore('https://api.example.com/v1/files')
<Route path="/files/*" element={
<FileTree store={store} routeBase="/files" rootPrefix="raw/" />
} />Every non-directory view renders a download icon when the store can produce a URL for the file. There are three strategies, picked by which options you pass to the store. Pick one per bucket — they're alternatives, not stacking layers.
| Strategy | Setup | Bytes flow | Use when |
|---|---|---|---|
A. Public URL (sync getUrl) |
Make bucket public + provide publicBaseUrl (R2) or just construct unsigned (S3) |
Browser ↔ bucket direct | Data is already public; cheapest, no tokens, no expiry. |
B. Presigned URL (async getDownloadUrl) |
Mint S3-compat token, configure presign: { ... } on store |
Browser ↔ bucket direct (signed, short-lived) | Private bucket; need revocability or expiring URLs. |
| C. Worker proxy (default fallback) | None — just use createHandlers + HttpStore |
Browser ↔ worker ↔ bucket | Small files; private bucket; don't want to manage signing. Capped by worker memory (~128 MB) and billed CPU. |
The lib's <FileTree> chooses automatically: prefers async getDownloadUrl if present, else sync getUrl, else hides the icon (or in HttpStore's case, points at /get which proxies). You configure which is wired by what you pass to the store constructor.
R2 (public access toggled in dashboard):
R2Store(bucket, {
publicBaseUrl: 'https://pub-<hash>.r2.dev', // dev/casual (rate-limited per CF)
// — or —
publicBaseUrl: 'https://data.example.com', // production, custom domain
})S3 (public bucket policy at the AWS console; no lib-side config):
S3Store({ bucket: 'open-data', region: 'us-west-2' })
// Static URL: https://open-data.s3.us-west-2.amazonaws.com/<key>Caveat on public-URL downloads: Cross-origin
<a download>clicks only force-download when the response carriesContent-Disposition: attachment. R2/S3 send that header iff each object's metadata sets it at upload time. Otherwise the browser navigates to the file (fine for text/image/video; may show raw garbage for binary like parquet). If you need guaranteed force-download on a public bucket, either upload withhttpMetadata.contentDisposition: 'attachment'set or use B. Presigned.
R2 (S3-compat token in worker secrets):
R2Store(bucket, {
presign: {
endpoint: env.R2_S3_ENDPOINT, // https://<acct>.r2.cloudflarestorage.com
bucket: 'my-bucket-name',
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
expiresIn: 3600, // default; signature expiry in seconds (max 604800)
},
})Mint the token at CF dashboard → R2 → Manage R2 API Tokens. Permission: Object Read, scope: only the buckets you're exposing. Server adds /presign endpoint automatically once getDownloadUrl is present.
HttpStore clients opt into using /presign with { presign: true } (opt-in to avoid stalling the icon against a 404 endpoint):
HttpStore('https://api.example.com/v1/files', { presign: true })S3 (credentialed, server-proxy or browser-direct):
S3Store({
bucket: 'private-data',
region: 'us-east-1',
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
presignExpiresIn: 3600,
})
// In-browser use: a visitor pastes their own creds at `/s3`/`/r2` in the
// site — `S3Store.getDownloadUrl` signs in-browser with those creds.If you don't set publicBaseUrl or presign, downloads route through the worker's /get?path=... endpoint. createHandlers already sends Content-Disposition: attachment; filename=..., so downloads name correctly. Trade-off: every byte hits worker memory, capped at ~128 MB.
Is your data intended to be public?
├── Yes
│ ├── R2 → publicBaseUrl: '<r2.dev or custom domain>' (A)
│ └── S3 → no config; S3Store({ bucket }) just works (A)
└── No (private)
├── Need expiring/revocable URLs?
│ ├── Yes → presign: { ... } (B)
│ └── No → just use the worker proxy (C)
└── Visitor browses their own bucket?
└── They paste creds into `/s3` or `/r2`; lib signs in-browser (B)
{
prefixes?: string[] // allow-list; '['']' = whole bucket
publicBaseUrl?: string // strategy A
presign?: { // strategy B
endpoint: string
bucket: string
accessKeyId: string
secretAccessKey: string
expiresIn?: number // default 3600
region?: string // default 'auto'
}
}{
bucket: string // required
region?: string // default 'us-east-1'; 'auto' for R2 via S3
endpoint?: string // R2/MinIO/LocalStack S3-compat endpoint
accessKeyId?: string // omit → unsigned (strategy A for public)
secretAccessKey?: string // ↳ both required → strategy B
sessionToken?: string // optional STS
prefixes?: string[] // allow-list
presignExpiresIn?: number // default 3600
fetch?: typeof fetch
}{
headers?: Record<string, string> // auth tokens, etc.
fetch?: typeof fetch
presign?: boolean // opt into /presign endpoint (server must expose it)
}MultiStore({ name: Store, ... })
// First path segment routes to a child; root list returns one dir per child.
// `getUrl` / `getDownloadUrl` are exposed only when *every* child has them.In-memory; for tests + demos. No URL strategy (use a real store for downloads).
import { createHandlers } from '@rdub/file-tree/server'
const handlers = createHandlers(store, {
basePath?: string, // default ''
corsOrigin?: string | null, // default '*'; null to omit CORS
})Endpoints (all GET):
| Path | Behavior |
|---|---|
<base>/list?prefix=&cursor=&limit= |
ListResult JSON |
<base>/get?path= |
Object bytes; Range honored; Content-Disposition: attachment set |
<base>/presign?path=&expires= |
{ url } JSON. Only mounted when the underlying store implements getDownloadUrl. |
<FileTree> doesn't bundle viewer deps. Reference renderers ship as their own sub-paths — import the ones you want and install their optional peer dep alongside:
import { FileTree } from '@rdub/file-tree/react'
import { renderMarkdown } from '@rdub/file-tree/renderers/markdown' // react-markdown + remark-gfm
import { ParquetViewer } from '@rdub/file-tree/renderers/parquet' // hyparquet
import { CsvViewer } from '@rdub/file-tree/renderers/csv' // (no peer)
import { NotebookViewer } from '@rdub/file-tree/renderers/notebook' // pulls react-markdown via markdown
import { renderCode } from '@rdub/file-tree/renderers/code' // highlight.js
import { renderJsonTree } from '@rdub/file-tree/renderers/json' // search, expand-all, copy-path; jq filter via optional `jq-web`
import { renderViewerActions } from './viewerActions' // ↗ SQL link, etc.
<FileTree
store={store}
routeBase="/files"
markdownRenderer={renderMarkdown}
parquetRenderer={ParquetViewer}
jsonRenderer={renderJsonTree}
csvRenderer={CsvViewer}
notebookRenderer={NotebookViewer}
codeRenderer={renderCode}
viewerActions={renderViewerActions}
/>| Renderer | Sub-path | Optional peer |
|---|---|---|
ParquetViewer |
@rdub/file-tree/renderers/parquet |
hyparquet |
renderMarkdown |
@rdub/file-tree/renderers/markdown |
react-markdown, remark-gfm |
CsvViewer |
@rdub/file-tree/renderers/csv |
— |
NotebookViewer |
@rdub/file-tree/renderers/notebook |
react-markdown + remark-gfm (via markdown) |
renderCode |
@rdub/file-tree/renderers/code |
highlight.js |
renderJsonTree |
@rdub/file-tree/renderers/json |
jq-web (optional, for jq filter only) |
renderJsonTree also comes in a parameterized form, for annotating domain-specific scalars (epoch timestamps, byte counts, ids) without forking the viewer. Same defaultNode convention as renderCell below:
import { makeJsonTreeRenderer } from '@rdub/file-tree/renderers/json'
const TS_KEYS = new Set(['start', 'end', 'requested_at'])
const renderJson = makeJsonTreeRenderer({
initialOpenDepth: 2,
renderValue: ({ key, value, defaultNode }) =>
key !== undefined && TS_KEYS.has(key) && typeof value === 'number'
? <>{defaultNode} <span className="dim">{new Date(value * 1000).toISOString()}</span></>
: defaultNode,
})
<FileTree store={store} routeBase="/files" jsonRenderer={renderJson} />renderValue is called for every string / number / boolean / null, with { value, path, key?, defaultNode } — path is the jq path (.foo[0].bar), key is the enclosing object key (unset for array elements). Containers aren't passed through it; they own the disclosure carets.
initialOpenDepth is how many container levels start expanded, default 1 (the root, nothing else). Depth counts containers, not keys — so a document of flat records, [{…}, {…}], wants 2; Infinity opens everything.
The peers are declared optional in peerDependenciesMeta, so installing only what you import is enough. Source lives at src/renderers/ — copy + tweak if you want different styling, paginate sizes, or language set.
jq-web is an Emscripten WASM module that expects to fetch jq.wasm from the same URL as its jq.js. In Vite/webpack apps that's usually a copy step — easiest path is to copy node_modules/jq-web/jq.wasm to your public/ dir (or use a copy-files/copy-webpack-plugin equivalent). Without that, typing in the jq input surfaces a WebAssembly.instantiate() error; the search / expand-all / copy-path features still work.
Built-in kinds (no renderer needed): plain text (<pre>), image (<img>), video (<video>), audio (<audio>), zip (entry list + per-entry preview, with client-side DecompressionStream fallback if Store.getZipEntries? isn't provided).
By default <FileTree> keeps the dir-listing filter, parquet pagination, and the JSON viewer's search/jq inputs in useState (in-memory, no URL writes). Opt in to shareable URL state by passing the bundled hook:
import { FileTree } from '@rdub/file-tree/react'
import { useUrlPersistedState } from '@rdub/file-tree/url-state'
<FileTree
store={store}
routeBase="/files"
usePersistedState={useUrlPersistedState}
/>The shipped helper binds: ?q=… (dir filter), ?page=N (parquet), ?json-q=… + ?jq=… (JSON viewer). Defaults are omitted from the URL.
@rdub/file-tree/url-state is the only path in the lib that imports use-prms — consumers who don't import it tree-shake the dep out. Bring-your-own (nuqs, your own URLSearchParams hook, etc.) by passing a function matching the PersistedState signature:
type PersistedState = <T extends string | number>(
key: string,
defaultValue: T,
) => [T, (value: T) => void]Per-file action buttons rendered next to the download icon. Signature:
(ctx: { store, path, kind, entry? }) => ReactNodeUse for "open in SQL REPL", "view raw", "share", etc. — consumer-app-specific. See site/src/viewerActions.tsx for a reference (parquet/CSV → /sql?url=...).
renderCell and renderCrumb let a consumer take over any cell of the directory listing, or any breadcrumb segment. Both receive the node the library would have rendered as defaultNode, so decorating doesn't mean reimplementing the default (icon, <Link>, size formatting):
type CellRenderer = (ctx: {
entry: Entry // { key, isDir, size?, lastModified? }
column: 'name' | 'size' | 'modified'
prefix: string // dir being listed
href: string // route this row links to
defaultNode: ReactNode
}) => ReactNode
type CrumbRenderer = (ctx: {
crumb: { label: string; to: string; path?: string } // `path` = store key
index: number
isLast: boolean
defaultNode: ReactNode
}) => ReactNodeThere's no "which cells does this apply to" config — the fn is called for every cell and answers that itself by returning defaultNode. E.g. annotating directories whose key encodes an ID with a human-readable name:
const deviceName = (key: string) => DEVICES[/(?:^|\/)awair-(\d+)\/?$/.exec(key)?.[1] ?? '']
<FileTree
store={store}
routeBase="/files"
renderCell={({ entry, column, defaultNode }) => {
if (column !== 'name') return defaultNode
const name = deviceName(entry.key)
return name ? <>{defaultNode} <span className="dim">{name}</span></> : defaultNode
}}
renderCrumb={({ crumb, defaultNode }) => {
const name = deviceName(crumb.path ?? '')
return name ? <>{defaultNode} <span className="dim">{name}</span></> : defaultNode
}}
/>Ignoring defaultNode gives you a total override of that cell. For replacing the listing wholesale (a different table engine, sortable columns, virtualization), fork src/react/DirListing.tsx — it's ~230 lines with no private imports.
The parquet viewer takes the same hook, one table down, via makeParquetViewer:
import { makeParquetViewer } from '@rdub/file-tree/renderers/parquet'
const ParquetViewer = makeParquetViewer({ // module scope, not inside render
renderCell: ({ column, value, defaultNode }) =>
column.name === 'station_id'
? <a href={`/stations/${value}`}>{defaultNode}</a>
: defaultNode,
})renderCell gets { value, column, row, rowIndex, defaultNode } — column carries { name, physicalType, logicalType, timeUnit, convertedType }, and rowIndex is absolute within the file, not within the page.
Presentation stops at the cell's contents, so three more options cover the column itself:
makeParquetViewer({
// merged over the viewer's own <td> / <th> style — no wrapper element,
// so the cell keeps its own ellipsis behaviour
cellProps: col => col.name === 'note' ? { style: { textAlign: 'center' } } : undefined,
headerProps: col => col.name === 'note' ? { style: { textAlign: 'center' } } : undefined,
// stats are the current row group's, straight from the footer — not
// reconstructible from the decoded rows a consumer sees
renderHeader: ({ column, stats, defaultNode }) =>
<>{defaultNode}{stats?.nullCount ? <sup>∅</sup> : null}</>,
})Numeric columns right-align by default, with tabular-nums, so digits line up down the column and magnitudes are comparable at a glance — headers follow their column. Columns read as temporal are excluded (they render as text, not quantities), as are BOOLEAN and the byte-array types. Turn it off with alignNumeric: false, or override per column with cellProps.
The default header also carries a title summarising the current row group's range (row group: 0 … 70578, or = 626 for a constant column) whenever the writer recorded statistics — a cheap orientation cue in a file with millions of rows.
Epoch integers are the worst-reading thing in a data table, and often the column you scan most. The viewer reads a column as temporal on the first signal that hits:
- Type annotation —
TIMESTAMP(unit)/DATE, vialogical_typeor the legacyconverted_type. Unambiguous, but plenty of writers emit epoch millis as a bareINT64, so it doesn't fire nearly as often as you'd hope. - Value range — every sampled value inside one unit's plausible-epoch window (~1990–2100). The windows are ~3 orders of magnitude apart, so seconds/millis/micros/nanos don't get confused with each other; the unit is read off the data rather than assumed.
- Name gate — (2) only applies to a column already named like a timestamp (
dt,ts,time,timestamp,date, or a_at/_time/_ts/_datesuffix). A large-integeridcolumn must never become a date, and a name alone never triggers anything.
Mixed units, out-of-window values, or a non-numeric in the column all fall back to rendering raw — a silently mis-rendered timestamp is worse than a visible integer. Output is always UTC with an explicit Z, elided to the coarsest form that loses nothing (2026-04-25 00:00Z, … 00:00:37Z, … 00:00:37.500Z, or a bare 2026-04-25 for a DATE), and the raw value stays on the cell's title. The schema panel labels a guess as INT64 · epoch millis (inferred), so you can always see which readings were inferred rather than declared.
To turn the heuristic off — annotated columns still format — use makeParquetViewer({ inferTimestamps: false }). formatTemporal / inferTemporalFormat are exported from the same subpath if you'd rather drive it yourself from a renderCell.
| Path | What |
|---|---|
@rdub/file-tree |
Store types, NotFoundError, ZipEntry types |
@rdub/file-tree/react |
<FileTree>, <DirListing>, <TextViewer>, <Breadcrumb>, <MediaViewer>, <ZipEntryList>, <ZipEntryPreview>, parsePath, asyncBufferFromStore, AUDIO/CODE_LANG/MarkdownRenderer/ParquetRenderer/ViewerActionCtx/CellRenderer/CrumbRenderer |
@rdub/file-tree/stores/r2 |
R2Store, R2StoreOptions, R2PresignOptions |
@rdub/file-tree/stores/s3 |
S3Store, S3StoreOptions (works for AWS S3, R2 via S3 API, MinIO) |
@rdub/file-tree/stores/http |
HttpStore, HttpStoreOptions |
@rdub/file-tree/stores/multi |
MultiStore |
@rdub/file-tree/stores/mock |
MockStore (in-memory) |
@rdub/file-tree/server |
createHandlers (HTTP endpoints over any Store) |
@rdub/file-tree/renderers/parquet |
ParquetViewer (peer: hyparquet) |
@rdub/file-tree/renderers/markdown |
renderMarkdown (peers: react-markdown, remark-gfm) |
@rdub/file-tree/renderers/csv |
CsvViewer (pure JS) |
@rdub/file-tree/renderers/notebook |
NotebookViewer (peers via markdown) |
@rdub/file-tree/renderers/code |
renderCode (peer: highlight.js) |
@rdub/file-tree/renderers/json |
renderJsonTree — search, jq filter (optional jq-web peer), expand/collapse-all, copy-jq-path on key click |
@rdub/file-tree/url-state |
useUrlPersistedState — opt-in URL-state hook (binds ?q=, ?page=, ?json-q=, ?jq= via use-prms) |
@rdub/file-tree/test/conformance |
runStoreConformance(makeStore) — vitest battery any new Store impl can opt into |
| Backend | Server-side | Browser-direct |
|---|---|---|
| R2 | ✅ (CFW binding + S3 API) | ✅ (via S3Store + R2 S3-compat creds) |
| S3 | ✅ (any runtime) | ✅ (pasted creds) |
| GitHub | TBD | TBD (raw.githubusercontent.com + REST tree) |
| GitLab | TBD | TBD |
| local FS | TBD (via disk-tree) |
n/a |
Other open items: <StoreAuthForm> for credential-paste UX, manifest-based static Store, cross-browser e2e (currently chromium only).
Store.listreturns directory entries withisDir: true(via the store's native delimiter) so the UI doesn't have to infer dirs fromkey.endsWith('/').Store.getreturns raw bytes; the UI decodes. New file kinds land viaparsePath'sParsedunion + a<…Renderer>slot — no Store changes needed.- The
prefixesallow-list onR2Store/S3Storeis a security boundary: same bucket may host browseable data (raw/) and private internals (cells/,_internal/); store rejects out-of-scopelist/get. NotFoundErroris detected viae instanceof Error && e.name === 'NotFoundError'(subpath bundles each carry their own copy, soinstanceofcross-bundle is unreliable).- The conformance harness (
@rdub/file-tree/test/conformance) is the contract: newStoreimpls add a one-line vitest invocation and get coverage for free.
See specs/handoff.md for full status + roadmap, and site/worker/README.md for the demo worker setup runbook (R2 presign).