Skip to content
Merged
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
23 changes: 23 additions & 0 deletions docs/12_worker_backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,29 @@ another workspace's shell.
`connect()` from inside the shell. The only path out of the
isolate is back through the host DO over `env.HOST`.

## Built-in custom commands

The worker backend registers two just-bash custom commands on every
exec:

- `git ...` forwards to the host workspace's `workspace.git.cli(...)`.
- `assets publish <path> [<expiry>]` forwards to the host
workspace's configured assets publisher and prints the share URL
to stdout.

`assets publish` accepts an absolute path or a path relative to the
current working directory. The optional expiry defaults to one hour;
a bare number is milliseconds, and `ms`, `s`, `m`, and `h` suffixes
are accepted (`30000`, `30s`, `5m`, `2h`). If the Workspace was not
constructed with an assets client, the command exits 1 with a clear
message.

The Dynamic Worker never receives the R2 bucket binding or signing
secrets. The host Durable Object configures the Workspace with an
assets client, and the command reaches that host-side capability over
the same `env.HOST.getWorkspace()` loopback as filesystem and git
calls.

## Why a loopback proxy

The natural impulse is to hand the Dynamic Worker the host DO's
Expand Down
154 changes: 154 additions & 0 deletions docs/14_assets_interface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# Assets interface

> [!IMPORTANT]
> This document describes the **intended design**. Names,
> signatures, and behaviours described here are targets. When in
> doubt, treat the code as authoritative for what runs and this doc
> as authoritative for what we're moving toward.

The assets module shares a file from the workspace with the outside
world. You give it a path inside the virtual filesystem and it
uploads the bytes to an R2 bucket, then hands back a presigned URL
that anyone can open until it expires.

The shell can produce a chart, a screenshot, or a build artifact;
`share` turns that into a link you can drop into a chat message or a
webhook without exposing the bucket or the rest of the workspace.

## Creating a client

```ts
import { createAssets } from "@cloudflare/workspace/assets";

const assets = createAssets({
ws,
bucket: env.ASSETS,
s3: { bucket: "agent-assets" },
env,
});
```

To make the worker-backend shell command available, attach the
client when constructing the `Workspace`:

```ts
const ws = new Workspace({
storage: ctx.storage,
backends: [new WorkerBackend(/* ... */)],
assets: (ws) => createAssets({ ws, bucket: env.ASSETS, s3: { bucket: "agent-assets" }, env }),
});
```

`createAssets` binds the workspace and the bucket once and returns a
client. The shape mirrors `createGitClient({ ws })`: bind the
dependencies up front so each `share` call only takes the path and
the options that vary.

Two distinct things named "bucket" are in play. `bucket` is the R2
binding the uploads go through. `s3.bucket` is the bucket's name,
which the binding can't report and the presigner needs to build the
URL.

## Sharing a file

```ts
const url = await assets.share("/workspace/out/chart.png", {
expiresAfter: 30 * 1000,
prefix: `/agent-${ws.sessionId}`,
});
```

`share` reads the file, uploads it, and returns a presigned `GET`
URL valid for `expiresAfter` milliseconds.

### Options

| Option | Meaning |
| --- | --- |
| `expiresAfter` | URL lifetime in milliseconds. Required. Rounded up to whole seconds and capped at seven days, the maximum a presigned URL allows. |
| `prefix` | Key prefix in the bucket, for example `agent-<session>`. Slashes are normalized. This is a key prefix, not a path inside the workspace. |
| `contentType` | Override the type inferred from the file extension. |
| `filename` | Override the download filename. Defaults to the basename of the shared path. |
| `disposition` | `inline` (the default) lets a browser render the file; `attachment` forces a download. |

## Object keys

The key written to R2 is:

```
<prefix>/<id>/<basename>
```

`id` is a fresh token for every call: sixteen random bytes encoded
with Crockford base32, about twenty-six characters. Two consequences
fall out of this:

- **Every share is unique.** Sharing the same file twice produces two
different keys, so a second share never overwrites the first and
the two URLs stay independent.
- **The path stays private.** Only the basename of the file appears
in the key. A share of `/workspace/secret/plans/q3.pdf` lands at
`<prefix>/<id>/q3.pdf` — the directories never leave the workspace.

## Object metadata

Each upload sets:

- `Content-Type`, inferred from the file extension or taken from the
`contentType` option. Unknown extensions fall back to
`application/octet-stream`.
- `Content-Disposition`, carrying the filename so a browser names the
download correctly.
- Custom metadata recording the source path inside the workspace, the
session id, and the expiry timestamp.

## Configuration

The presigner signs requests for R2's S3-compatible endpoint, so it
needs an account id, an access key id, a secret access key, and the
bucket name. Pass them on the `s3` object, or let the client read
them from the environment you hand it:

| Value | `s3` field | Environment fallback |
| --- | --- | --- |
| Account id | `accountId` | `CLOUDFLARE_ACCOUNT_ID` |
| Access key id | `accessKeyId` | `R2_ACCESS_KEY_ID`, then `AWS_ACCESS_KEY_ID` |
| Secret access key | `secretAccessKey` | `R2_SECRET_ACCESS_KEY`, then `AWS_SECRET_ACCESS_KEY` |
| Endpoint | `endpoint` | `R2_ENDPOINT`, otherwise derived from the account id |

Explicit `s3` fields win over the environment. The bucket name has no
environment fallback and is always required. When a credential can't
be found, `createAssets` throws with a message naming the missing
value rather than failing later as an opaque permission error from
R2.

The R2 binding alone can't mint presigned URLs, which is why the
credentials are needed on top of it. Create an R2 API token scoped to
the bucket and supply its keys through the environment.

## Worker-backend shell command

When a `Workspace` is constructed with an assets client, the worker
backend's just-bash shell exposes:

```sh
assets publish <path> [<expiry>]
```

The command writes the share URL to stdout. `<path>` may be absolute
or relative to the current working directory. `<expiry>` defaults to
one hour; a bare number is milliseconds, and `ms`, `s`, `m`, and `h`
suffixes are accepted.

The command still runs the publish on the host Durable Object. The
Dynamic Worker does not receive the R2 bucket binding or signing
secrets.

## Expiry and cleanup

`expiresAfter` controls how long the URL works, not how long the
object lives. When the signature expires the link stops working, but
the object stays in the bucket. To reclaim the space, set an R2
lifecycle rule on the bucket, or sweep objects using the expiry
timestamp recorded in their custom metadata. Automatic cleanup is not
part of this module today.
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ above, then dive into the area you're working on.
| [11. Lifecycle](./11_lifecycle.md) | DO incarnations, container lifetime, capnweb session lifecycle, and hibernation. |
| [12. Worker backend](./12_worker_backend.md) | Running the shell as just-bash inside a Dynamic Worker loaded through `env.LOADER`. |
| [13. Git interface](./13_git_interface.md) | `workspace.git` and the `git` CLI inside the shell, backed by isomorphic-git. |
| [14. Assets interface](./14_assets_interface.md) | `share` a workspace file to R2 and get back a presigned URL. |

## High-level API

Expand Down
14 changes: 14 additions & 0 deletions examples/assets/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copy to .dev.vars for local development, or set these as secrets
# in production with `wrangler secret put <NAME>`.
#
# The R2 binding alone can't mint a presigned URL, so the assets
# client needs R2 S3 credentials. Create an R2 API token scoped to
# the bucket and fill in the values below.
#
# Note: this example is production-only. The image model runs on
# Cloudflare's network and the presigned link points at R2, so a
# local dev stack won't produce a working link even with these set.

R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
CLOUDFLARE_ACCOUNT_ID=
5 changes: 5 additions & 0 deletions examples/assets/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
dist/
node_modules/
.wrangler/
.dev.vars*
!.dev.vars.example
101 changes: 101 additions & 0 deletions examples/assets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# assets example

> [!IMPORTANT]
> **PREVIEW ONLY** This package is provided as a preview for feedback only.
> APIs are unstable and the design is subject to change.

A Cloudflare Worker + Durable Object that turns a text prompt into
an image and hands back a shareable link. One shot: `POST /prompt`,
get a URL.

The Durable Object runs the prompt through a Workers AI
text-to-image model, writes the generated PNG into its `Workspace`,
then uploads that file to R2 and returns a presigned URL through
[`@cloudflare/workspace/assets`](../../docs/14_assets_interface.md).

> [!NOTE]
> This is a **production-only** example. The presigner needs R2 S3
> credentials, and the image model runs on Cloudflare's network, so
> `wrangler dev` against a local stack won't produce a working link.
> Deploy it and hit the deployed URL.

## Architecture

```
client ─► Worker POST /prompt
│ (DO RPC call)
AssetWorkspace DO ──► env.AI.run(flux) generate the image
──► Workspace.fs.writeFile store it in the VFS
──► createAssets(...).share upload to R2 + presign
{ path, url }
```

1. The Worker accepts `POST /prompt` and forwards the prompt to a
single `AssetWorkspace` Durable Object.
2. The DO holds a backend-less `Workspace` — it only needs the
filesystem, not a shell. It runs the prompt through the
[FLUX.2 \[klein\] 9B](https://developers.cloudflare.com/workers-ai/models/flux-2-klein-9b/)
model on `env.AI`, which returns the image as base64.
3. The DO decodes the image, writes it to
`/workspace/<uuid>.png`, then calls `createAssets(...).share`
to upload the file to R2 and presign a `GET` URL.
4. The response is `{ path, url }`. The link is valid for one hour.

## Configuration

The bucket binding alone can't mint a presigned URL, so the
presigner needs R2 S3 credentials. Create an R2 API token scoped to
the bucket. The credential names are listed in
[`.dev.vars.example`](.dev.vars.example); copy it to `.dev.vars` to
fill them in, then set the same values as secrets for production:

```sh
wrangler secret put R2_ACCESS_KEY_ID
wrangler secret put R2_SECRET_ACCESS_KEY
wrangler secret put CLOUDFLARE_ACCOUNT_ID
```

The bucket name is supplied to the assets client through the
`ASSETS_BUCKET_NAME` var in `wrangler.jsonc`; keep it in step with
the `bucket_name` on the `ASSETS` binding.

Create the bucket once before the first deploy:

```sh
wrangler r2 bucket create workspace-assets-example
```

## HTTP surface

```
POST /prompt { "prompt": "..." }
→ { "path": "/workspace/<uuid>.png", "url": "https://..." }
```

## Deploy and run

```sh
npm run deploy --workspace @example/workspace-assets

curl -X POST https://workspace-assets-example.<your-subdomain>.workers.dev/prompt \
-H 'content-type: application/json' \
-d '{"prompt":"a sunset over the alps, oil painting"}'
```

The response carries a `url`; open it to see the generated image.
The link expires after an hour, after which the object stays in the
bucket but the URL stops working. See the
[assets interface](../../docs/14_assets_interface.md) for the
cleanup story.

## Layout

```
examples/assets/
wrangler.jsonc Worker + DO + AI + R2 bindings
.dev.vars.example R2 S3 credential names; copy to .dev.vars
src/index.ts Worker handler + DO (AssetWorkspace)
```
20 changes: 20 additions & 0 deletions examples/assets/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "@example/workspace-assets",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Example Worker + Durable Object that turns a prompt into an image with Workers AI, writes it to the workspace, and returns a shareable link via @cloudflare/workspace/assets.",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@cloudflare/workspace": "*"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260601.1",
"typescript": "^6.0.3",
"wrangler": "^4.95.0"
}
}
Loading