Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 85 additions & 13 deletions apps/dev-playground/client/src/routes/files.route.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { DirectoryEntry, FilePreview } from "@databricks/appkit-ui/react";
import {
Button,
Card,
DirectoryList,
FileBreadcrumb,
FilePreviewPanel,
NewFolderInput,
useSignedUrl,
} from "@databricks/appkit-ui/react";
import { createFileRoute, retainSearchParams } from "@tanstack/react-router";
import { FolderPlus, Loader2, Upload } from "lucide-react";
import { FolderPlus, Link, Loader2, Upload } from "lucide-react";
import {
type RefObject,
useCallback,
Expand Down Expand Up @@ -71,6 +73,17 @@ function FilesRoute() {
[volumeKey],
);

const {
signedUrl,
loading: signedUrlLoading,
error: signedUrlError,
copied,
expired: signedUrlExpired,
generate: generateSignedUrl,
copyToClipboard,
reset: resetSignedUrl,
} = useSignedUrl(apiUrl);

const loadDirectory = useCallback(
async (path?: string) => {
if (!volumeKey) return;
Expand Down Expand Up @@ -173,6 +186,7 @@ function FilesRoute() {
loadDirectory(entryPath);
} else {
setSelectedFile(entryPath);
resetSignedUrl();
loadPreview(entryPath);
}
};
Expand Down Expand Up @@ -391,18 +405,76 @@ function FilesRoute() {
}
/>

<FilePreviewPanel
className="flex-1 min-w-0"
selectedFile={selectedFile}
preview={preview}
previewLoading={previewLoading}
onDownload={(path) =>
window.open(apiUrl("download", { path }), "_blank")
}
onDelete={handleDelete}
deleting={deleting}
imagePreviewSrc={(p) => apiUrl("raw", { path: p })}
/>
<div className="flex-1 min-w-0 space-y-4">
<FilePreviewPanel
selectedFile={selectedFile}
preview={preview}
previewLoading={previewLoading}
onDownload={(path) =>
window.open(apiUrl("download", { path }), "_blank")
}
onDelete={handleDelete}
deleting={deleting}
imagePreviewSrc={(p) => apiUrl("raw", { path: p })}
/>

{selectedFile && !previewLoading && preview && (
<Card className="p-4 space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-foreground">
Pre-signed URL
</h4>
<Button
variant="outline"
size="sm"
disabled={signedUrlLoading}
onClick={() =>
selectedFile && generateSignedUrl(selectedFile)
}
>
{signedUrlLoading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Link className="h-4 w-4 mr-2" />
)}
{signedUrlLoading ? "Generating..." : "Generate URL"}
</Button>
</div>

{signedUrlError && (
<p className="text-sm text-destructive">{signedUrlError}</p>
)}

{signedUrl && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={signedUrl.url}
className="flex-1 rounded-md border border-input bg-muted/30 px-3 py-1.5 text-xs font-mono truncate"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<Button
variant="outline"
size="sm"
onClick={copyToClipboard}
>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
<p
className={`text-xs ${signedUrlExpired ? "text-destructive" : "text-muted-foreground"}`}
>
{signedUrlExpired
? "Expired — generate a new URL"
: `Expires: ${new Date(signedUrl.expiresAt).toLocaleString()}`}
</p>
</div>
)}
</Card>
)}
</div>
</div>
</div>
</div>
Expand Down
66 changes: 59 additions & 7 deletions docs/docs/plugins/files.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ Routes are mounted at `/api/files/*`. All routes except `/volumes` execute in us
| GET | `/:volumeKey/list` | `?path` (optional) | `DirectoryEntry[]` |
| GET | `/:volumeKey/read` | `?path` (required) | `text/plain` body |
| GET | `/:volumeKey/download` | `?path` (required) | Binary stream (`Content-Disposition: attachment`) |
| GET | `/:volumeKey/download-url` | `?path` (required), `?expireInSeconds` (optional, 1–3600) | `PresignedDownloadUrl` |
| GET | `/:volumeKey/raw` | `?path` (required) | Binary stream (inline for safe types, attachment for unsafe) |
| GET | `/:volumeKey/exists` | `?path` (required) | `{ exists: boolean }` |
| GET | `/:volumeKey/metadata` | `?path` (required) | `FileMetadata` |
Expand Down Expand Up @@ -154,7 +155,7 @@ Every operation runs through the interceptor pipeline with tier-specific default
| Tier | Cache | Retry | Timeout | Operations |
| ------------ | ----- | ----- | ------- | ------------------------------------- |
| **Read** | 60 s | 3x | 30 s | list, read, exists, metadata, preview |
| **Download** | none | 3x | 30 s | download, raw |
| **Download** | none | 3x | 30 s | download, download-url, raw |
| **Write** | none | none | 600 s | upload, mkdir, delete |

Retry uses exponential backoff with a 1 s initial delay.
Expand All @@ -169,15 +170,13 @@ Write operations (`upload`, `mkdir`, `delete`) automatically invalidate the cach

## Programmatic API

The `exports()` API is a callable that accepts a volume key and returns a `VolumeHandle`. The handle exposes all `VolumeAPI` methods directly (service principal, logs a warning) and an `asUser(req)` method for OBO access (recommended).
The `files()` export is a callable that accepts a volume key and returns a `VolumeHandle`. All methods require OBO access via `asUser(req)` — calling them without a user context throws an error.

```ts
// OBO access (recommended)
// OBO access (required)
const entries = await appkit.files("uploads").asUser(req).list();
const content = await appkit.files("exports").asUser(req).read("report.csv");

// Service principal access (logs a warning encouraging OBO)
const entries = await appkit.files("uploads").list();

// Named accessor
const vol = appkit.files.volume("uploads");
Expand All @@ -191,6 +190,7 @@ await vol.asUser(req).list();
| `list` | `(directoryPath?: string)` | `DirectoryEntry[]` |
| `read` | `(filePath: string, options?: { maxSize?: number })` | `string` |
| `download` | `(filePath: string)` | `DownloadResponse` |
| `createDownloadUrl` | `(filePath: string, options?: { expireInSeconds?: number })` | `PresignedDownloadUrl` |
| `exists` | `(filePath: string)` | `boolean` |
| `metadata` | `(filePath: string)` | `FileMetadata` |
| `upload` | `(filePath: string, contents: ReadableStream \| Buffer \| string, options?: { overwrite?: boolean })` | `void` |
Expand All @@ -200,6 +200,44 @@ await vol.asUser(req).list();

> `read()` loads the entire file into memory as a string. Files larger than 10 MB (default) are rejected — use `download()` for large files, or pass `{ maxSize: <bytes> }` to override.

### `download` vs `createDownloadUrl`

| | `download` | `createDownloadUrl` |
| --- | --- | --- |
| **How it works** | Streams the file through the AppKit server | Returns a pre-signed URL pointing to cloud storage (S3/ADLS/GCS) |
| **Best for** | Server-side processing, or clients that can't reach cloud storage | Large file downloads, reducing server load |
| **Proxy** | Yes — file bytes flow through the server | No — client fetches directly from cloud storage |

### Pre-signed download URLs

`createDownloadUrl` requests a short-lived, pre-signed URL from Unity Catalog. The URL points directly to cloud storage and bypasses the Databricks Apps proxy, making it ideal for large file downloads.

```ts
const { url, headers, expiresAt } = await appkit
.files("uploads")
.asUser(req)
.createDownloadUrl("data/report.parquet");

// Client fetches directly from cloud storage using `url` and `headers`.
```

- **Expiration**: defaults to 900 seconds (15 min), configurable from 1 to 3600 via `expireInSeconds`.
- **OBO-only**: throws if called without user context.
- **Fallback**: if the workspace does not support pre-signed URLs, the `/download-url` HTTP route returns an error with `"fallback": "download"` so clients can fall back to the proxied `/download` endpoint.

**Security: response headers contain cloud credentials.**
The `headers` object returned by `createDownloadUrl` may contain cloud-provider authentication tokens (e.g., SAS tokens for ADLS, signed headers for S3). Do not log, cache, or expose these headers beyond the immediate client download.

#### Known error codes

| Code | Meaning |
| --- | --- |
| `PRESIGNED_URL_NOT_ENABLED` | Pre-signed URL feature is not enabled on this workspace |
| `PRESIGNED_URL_NETWORK_ZONE_UNKNOWN` | Requester's network zone is unknown (private link / firewall) |
| `PRESIGNED_URL_NOT_AVAILABLE` | Endpoint not available (older workspace version) |
| `PRESIGNED_URL_FAILED` | Generic / unrecognised error |


## Path resolution

Paths can be **absolute** or **relative**:
Expand All @@ -218,6 +256,19 @@ The `list()` method with no arguments lists the volume root.
type DirectoryEntry = files.DirectoryEntry;
type DownloadResponse = files.DownloadResponse;

interface PresignedDownloadUrl {
/** Pre-signed URL pointing directly to cloud storage. */
url: string;
/**
* Headers the client must include when fetching the pre-signed URL.
* May contain cloud-provider authentication tokens — do not log or expose.
*/
headers: Record<string, string>;
/** ISO 8601 timestamp when the pre-signed URL expires. */
expiresAt: string;
}


interface FileMetadata {
/** File size in bytes. */
contentLength: number | undefined;
Expand Down Expand Up @@ -247,6 +298,7 @@ interface VolumeAPI {
list(directoryPath?: string): Promise<DirectoryEntry[]>;
read(filePath: string, options?: { maxSize?: number }): Promise<string>;
download(filePath: string): Promise<DownloadResponse>;
createDownloadUrl(filePath: string, options?: { expireInSeconds?: number }): Promise<PresignedDownloadUrl>;
exists(filePath: string): Promise<boolean>;
metadata(filePath: string): Promise<FileMetadata>;
upload(filePath: string, contents: ReadableStream | Buffer | string, options?: { overwrite?: boolean }): Promise<void>;
Expand All @@ -255,7 +307,7 @@ interface VolumeAPI {
preview(filePath: string): Promise<FilePreview>;
}

/** Volume handle: all VolumeAPI methods (service principal) + asUser() for OBO. */
/** Volume handle: methods require OBO via asUser(req). Throws without user context. */
type VolumeHandle = VolumeAPI & {
asUser: (req: Request) => VolumeAPI;
};
Expand All @@ -275,7 +327,7 @@ Built-in extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`, `.bmp`, `

Routes use `this.asUser(req)` so operations execute with the requesting user's Databricks credentials (on-behalf-of / OBO). The `/volumes` route is the only exception since it only reads plugin config.

The programmatic API returns a `VolumeHandle` that exposes all `VolumeAPI` methods directly (service principal) and an `asUser(req)` method for OBO access. Calling any method without `asUser()` logs a warning encouraging OBO usage but does not throw. OBO access is strongly recommended for production use.
The programmatic API returns a `VolumeHandle` whose methods require OBO access via `asUser(req)`. Calling any method without a user context throws an error.

## Resource requirements

Expand Down
5 changes: 5 additions & 0 deletions packages/appkit-ui/src/react/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,8 @@ export {
type UseChartDataResult,
useChartData,
} from "./use-chart-data";
export {
type SignedUrl,
type UseSignedUrlResult,
useSignedUrl,
} from "./use-signed-url";
Loading
Loading