Skip to content

workspace: add assets module for sharing files via R2 - #10

Merged
aron-cf merged 17 commits into
mainfrom
assets
Jun 16, 2026
Merged

workspace: add assets module for sharing files via R2#10
aron-cf merged 17 commits into
mainfrom
assets

Conversation

@aron-cf

@aron-cf aron-cf commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

This change adds an assets publishing surface for @cloudflare/workspace. A Workspace can now upload a file from its virtual filesystem to R2 and return a short-lived presigned URL. The URL key is unique per share, includes only the filename, and carries the right content type, disposition, and metadata.

The public API lives at @cloudflare/workspace/assets and follows the same factory shape as the git client: bind the workspace, bucket, and signing configuration once, then call share() for each file.

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

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

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

R2 bucket bindings can upload objects, but they cannot mint presigned URLs. The signer therefore uses R2's S3-compatible credentials, resolved from explicit s3 options or from the usual environment values: CLOUDFLARE_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and R2_ENDPOINT when set. Uploads still go through the R2 binding; only the presigned GET URL uses the S3 signing path.

The object key has this shape:

<prefix>/<random-id>/<filename>

The random id is 16 bytes encoded as Crockford base32, so sharing the same file twice creates two distinct objects. Only the filename appears in the URL path; the full workspace path is stored only in R2 custom metadata. The body is streamed from the virtual filesystem through FixedLengthStream before calling bucket.put(), because R2 requires stream uploads to have a known length.

Workspaces can also expose the publisher to the worker backend's just-bash shell by attaching the assets client at construction time:

const ws = new Workspace({
  storage: ctx.storage,
  backends: [
    new WorkerBackend({
      loader: env.LOADER,
      workspace: { binding: "MyWorkspace", id: ctx.id.toString() },
      ctx,
    }),
  ],
  assets: (ws) =>
    createAssets({
      ws,
      bucket: env.ASSETS,
      s3: { bucket: "agent-assets" },
      env,
    }),
});

That makes a new shell command available inside the Dynamic Worker:

assets publish /workspace/out/image.png
assets publish ./out/image.png 30s
assets publish report.pdf 5m
assets publish build.zip 2h

The command prints the share URL to stdout. It accepts absolute paths or paths 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 supported. The Dynamic Worker never receives the R2 binding or signing secrets; it forwards to the host workspace over the existing RPC loopback.

This also adds an examples/assets project that turns a one-shot prompt into an image link. The Worker accepts POST /prompt, runs the FLUX.2 [klein] 9B Workers AI image model, writes the generated PNG into a backend-less Workspace, publishes it through the assets client, and returns { path, url }.

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"}'
{
  "path": "/workspace/0d58d8c2-7c5c-48a2-bd22-c25f9dd5c824.png",
  "url": "https://<account>.r2.cloudflarestorage.com/..."
}

The example is production-only. It needs an R2 bucket, Workers AI, and R2 S3 credentials set as secrets. A .dev.vars.example documents the required values, but a local dev stack will not produce a working public link.

Manual verification:

wrangler r2 bucket create workspace-assets-example
wrangler secret put R2_ACCESS_KEY_ID
wrangler secret put R2_SECRET_ACCESS_KEY
wrangler secret put CLOUDFLARE_ACCOUNT_ID
npm run deploy --workspace @example/workspace-assets

Then call POST /prompt and open the returned URL before it expires.

The change is covered by unit and integration tests for key generation, MIME inference, SigV4 signing, fixed-length R2 uploads, share() behavior against a real SQLite-backed Workspace, WorkspaceStub.assets, and the worker-backend assets publish command. The validation run was:

npm run format
npx biome check .
npx vitest run --workspace @cloudflare/workspace
npx vitest run --workspace @cloudflare/workspace -- --config vitest.config.worker-backend.ts
npx tsc -p packages/workspace/tsconfig.build.json --noEmit
npm run build --workspace @cloudflare/workspace
npm run typecheck --workspace @cloudflare/example-think
npm run typecheck --workspace @example/workspace-assets

Documentation was added for the assets interface, the worker backend shell command, and the new example. The think example now wires the same assets publisher into both its model-facing share tool and the worker shell's assets publish command when the R2 credentials are present.

aron-cf added 14 commits June 16, 2026 20:10
The assets module needs a short, URL-safe token to make every share
unique. A hyphenated UUID is 36 characters; Crockford base32 over the
same 16 random bytes is 26 lowercase characters with no hyphens and no
visually ambiguous letters.

Add encodeBase32 / decodeBase32 and a randomId helper that pulls 16
bytes from crypto.getRandomValues. The random source is injectable so
tests can pin the output. Decoding folds the ambiguous letters back so
a transcribed token still resolves.
Shared assets need a Content-Type so a browser opening the presigned
URL renders them correctly. A full mime database is overkill for the
file kinds an agent shares, so ship a small table covering images,
text, common documents, archives, and media, with an
application/octet-stream fallback.

The lookup keys off the basename's extension and is case-insensitive.
Dotfiles and extensionless paths take the fallback.
Presigning an R2 GET URL is a handful of HMAC-SHA256 steps over
deterministic strings, so implement SigV4 in house rather than pull a
dependency. The presigned-GET path uses UNSIGNED-PAYLOAD, so the URL
is a pure function of the inputs and the clock and no request body is
read.

Keep a sha256HexStream helper that hashes a ReadableStream
incrementally in constant space, for the future signed-upload path a
buffering library can't serve. Tests validate the signing-key
derivation and empty-string digest against AWS's published vectors.
The constructor already accepts sessionId and forwards it to mount
factories, but never surfaced it. The assets module tags each shared
object with the originating session, so expose a read-only getter that
returns the configured id (empty string when unset).
createAssets({ ws, bucket, s3 }) binds a workspace and an R2 bucket
and returns a client whose share(path, opts) uploads a VFS file to R2
and returns a presigned GET URL.

The object key is prefix/id/basename: a fresh base32 id per share, so
the same file shared twice yields two distinct keys, and only the
basename lands in the key so the full VFS path is never exposed. The
body streams straight from ws.fs.readFile into bucket.put without
buffering. Content-Type is inferred from the extension, the
Content-Disposition carries the filename, and custom metadata records
the source path, session id, and expiry.

Credentials and the account-derived endpoint resolve from the standard
R2 / Cloudflare env vars when not passed explicitly. The tsconfig
gains the node types entry dofs already carries, matching the
node:crypto already externalised in the rolldown config.
Add the ./assets entry to the package exports and the rolldown input
map so consumers can import createAssets from
@cloudflare/workspace/assets. The module builds to its own chunk;
node:crypto stays external, as it already was in the config.
Document the assets module: createAssets, the share options, the
prefix/id/basename key shape and why every share is unique, the object
metadata, the credential resolution from the environment, and the
expiry-versus-cleanup distinction. Link it from the docs index and the
workspace package README.
Give the triage agent a share tool that uploads a workspace file to
R2 and returns a time-limited link, so it can hand the user an
artifact it produced. The tool binds the bucket and credentials with
createAssets and exposes only a path and an optional lifetime to the
model.

The presigner needs R2 S3 credentials the bucket binding can't
surface, so the tool is registered only when R2_ACCESS_KEY_ID and
R2_SECRET_ACCESS_KEY are present. Without them the agent runs
unchanged and the tool is omitted. Add the ASSETS bucket binding and
the credential vars to the wrangler config.
Add a Worker plus Durable Object example that turns a one-shot
POST /prompt into an image. The Durable Object runs the prompt
through a Workers AI text-to-image model, writes the generated PNG
into a backend-less Workspace, then uploads the file to R2 and
returns a presigned link through createAssets.

The example is production-only: the presigner needs R2 S3
credentials set as secrets, and the image model runs on Cloudflare's
network, so a local dev stack can't produce a working link. The
README covers the bucket, credentials, deploy, and request shape.
List the R2 S3 credential names the presigner needs in a
.dev.vars.example file so the credentials are discoverable without
reading the source. Ignore real .dev.vars files while keeping the
example tracked, and point the README at it.
The model call extracted env.AI.run into a local and invoked it
unbound, so the binding's run() ran with an undefined `this` and
threw "Cannot set properties of undefined (setting '#options')" from
inside the binding. Keep the call on env.AI through a typed view of
the binding so run() retains its receiver.
Fix the assets example comment so the documented response shape
matches the code, and document the think example's optional share tool
setup: the ASSETS bucket, the R2 S3 credential secrets, and the fact
that the tool is omitted when credentials are absent.

Add tests for live assets error paths that reviewers identified as
uncovered: NaN expiry, the seven-day presigned URL cap, missing-file
rejection without uploading, and the remaining resolveS3 failure
branches.
R2 rejects an arbitrary ReadableStream body because it cannot infer a
content length. Size the VFS file with fs.stat, pipe the read stream
through a FixedLengthStream, and pass the readable half to bucket.put
so the binding receives a known-length stream without buffering the
file.

The regression test installs a Workers-compatible FixedLengthStream
stand-in for the Node runner and asserts the fake R2 bucket receives
the fixed-length readable with the file's byte length.
Expose an optional assets client on Workspace and WorkspaceStub so the
worker-backend shell can publish files without receiving R2 bindings or
signing secrets in the Dynamic Worker. The new `assets publish <path>
[<expiry>]` just-bash command forwards to that host-side capability and
prints the share URL to stdout.

The command resolves relative paths against cwd, defaults expiry to one
hour, and accepts bare millisecond values plus ms, s, m, and h suffixes.
When assets are not configured it exits with a clear error.

Wire the think example to configure WorkspaceOptions.assets when the R2
credentials are present, and document the shell command in the worker
backend and assets docs.

function errorJSON(error: unknown, status: number): Response {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), {
aron-cf added 2 commits June 16, 2026 20:24
Require an endpoint source before wiring the think example's assets
publisher or share tool. R2 credentials alone are not enough because
the assets signer derives its endpoint from CLOUDFLARE_ACCOUNT_ID or
uses R2_ENDPOINT when one is provided. Without this guard, a partially
configured deployment could throw during Workspace construction or
while building the toolset.

Also restore the WorkspaceGitStub documentation to the git stub and
add a separate comment for WorkspaceAssetsStub so each RPC surface is
described by the right block.
@aron-cf
aron-cf marked this pull request as ready for review June 16, 2026 19:42
@aron-cf
aron-cf merged commit 746d98e into main Jun 16, 2026
9 checks passed
@aron-cf
aron-cf deleted the assets branch June 16, 2026 20:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants