Skip to content

[Stream] Add support for serving videos locally + watermark/caption upload support#13234

Merged
natewong1313 merged 22 commits into
mainfrom
nwong/STREAM-6745
Apr 21, 2026
Merged

[Stream] Add support for serving videos locally + watermark/caption upload support#13234
natewong1313 merged 22 commits into
mainfrom
nwong/STREAM-6745

Conversation

@natewong1313
Copy link
Copy Markdown
Member

@natewong1313 natewong1313 commented Apr 1, 2026

This PR adds local video serving support to the Stream binding, allowing users to fetch uploaded video blobs via preview URLs. It also adds support for caption and watermark uploads.

Architecture

The local Stream binding runs as three workerd services:

  1. Storage service -- disk-backed persistence for the Durable Object
  2. Object service (object.worker.ts) -- a Durable Object that stores video metadata in SQLite and video/watermark/caption blobs in blob storage
  3. Binding service (binding.worker.ts) -- a WorkerEntrypoint that exposes the Stream RPC API (upload, video().details(), videos.list(), etc.) and a fetch() handler for serving video blobs

Video serving flow

When a user uploads a video via the binding, the returned StreamVideo object includes a preview URL pointing at the user-facing server (e.g. http://localhost:8787/cdn-cgi/mf/stream/<id>/watch).

When a browser or user code fetches this URL:

  1. Entry worker (entry.worker.ts) matches the /cdn-cgi/mf/stream/ prefix and dispatches to the stream binding service via streamService.fetch(request)
  2. Binding worker (binding.worker.ts) parses the video ID from the URL, retrieves the blob from the Durable Object, and returns it as a video/mp4 response

Preview URL construction

The binding worker needs to construct full, externally-reachable preview URLs but runs inside workerd with no direct knowledge of the user-facing port. Preview URLs must also be stable across workerd runtime restarts (which happen on every code/config change during dev).

This is solved via a /core/public-url loopback route combined with a publicUrl config option on Miniflare:

  • Miniflare exposes a /core/public-url loopback route that returns the configured publicUrl, falling back to the runtime entry URL if not set. publicUrl is also exposed as a mutable public field on the Miniflare instance so it can be updated after construction (without restarting workerd).
  • Wrangler passes the proxy server's URL (e.g. http://localhost:8787), constructed from the dev server config (hostname, port, secure) in LocalRuntimeController.
  • Vite plugin passes the Vite dev/preview server's URL, and dynamically updates miniflare.publicUrl after the server starts listening so preview URLs reflect the actual port (handling Vite's port-bump behaviour when the configured port is taken).

At request time, the binding worker fetches the loopback route to resolve the current public URL. Because this is a loopback request (not a static env binding), it always reflects the latest value — even after Vite bumps the port or workerd restarts.

To keep URL construction correct and consistent across consumers, Miniflare now exports two helper functions:

  • buildPublicUrl({ hostname, port, secure }) — builds a client-reachable URL string for a local server.
  • getLocallyAccessibleHost(host) — normalises a hostname for use in a client-reachable URL: maps wildcard listen addresses (0.0.0.0, ::, *) to 127.0.0.1, brackets IPv6 addresses (e.g. ::1[::1], fe80::1[fe80::1]), and leaves hostnames and IPv4 addresses unchanged.

Both Wrangler and the Vite plugin use buildPublicUrl to construct the publicUrl they pass to Miniflare, so wildcard/IPv6 listen addresses produce valid, reachable URLs without any consumer having to re-implement the logic.


  • Tests
    • Tests included/updated
    • Automated tests not possible - manual testing has been completed as follows:
    • Additional testing not necessary because:
  • Public documentation
    • Cloudflare docs PR(s):
    • Documentation not necessary because: binding is not public yet

A picture of a cute animal (not mandatory, but encouraged)
image

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 1, 2026

UnknownError: ProviderInitError

github run

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 1, 2026

@natewong1313 Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

@workers-devprod workers-devprod requested review from a team and jamesopstad and removed request for a team April 1, 2026 20:14
@workers-devprod
Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • @cloudflare/wrangler
Show detailed file reviewers
  • packages/miniflare/src/index.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/core/index.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/stream/index.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/core/constants.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/core/entry.worker.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/stream/binding.worker.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/stream/object.worker.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/stream/schemas.ts: [@cloudflare/wrangler]
  • packages/miniflare/test/plugins/stream/index.spec.ts: [@cloudflare/wrangler]

devin-ai-integration[bot]

This comment was marked as resolved.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 1, 2026

🦋 Changeset detected

Latest commit: 40306e2

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 1, 2026

create-cloudflare

npm i https://pkg.pr.new/create-cloudflare@13234

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@13234

miniflare

npm i https://pkg.pr.new/miniflare@13234

@cloudflare/pages-shared

npm i https://pkg.pr.new/@cloudflare/pages-shared@13234

@cloudflare/unenv-preset

npm i https://pkg.pr.new/@cloudflare/unenv-preset@13234

@cloudflare/vite-plugin

npm i https://pkg.pr.new/@cloudflare/vite-plugin@13234

@cloudflare/vitest-pool-workers

npm i https://pkg.pr.new/@cloudflare/vitest-pool-workers@13234

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@13234

wrangler

npm i https://pkg.pr.new/wrangler@13234

commit: 40306e2

@jamesopstad
Copy link
Copy Markdown
Contributor

Moving this to draft as it looks like it's not yet ready for review.

@jamesopstad jamesopstad marked this pull request as draft April 2, 2026 10:04
@jamesopstad jamesopstad removed their request for review April 2, 2026 10:07
@natewong1313 natewong1313 force-pushed the nwong/STREAM-6745 branch 2 times, most recently from aa52d8a to edcd5ed Compare April 2, 2026 15:40
ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

This comment was marked as resolved.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 2, 2026

Good, duplex: "half" is used in another similar forwarding pattern in the same file (line 1434), so this is consistent.

Now let me think about whether there are any actual logic bugs or issues remaining:

  1. The loopback forwarding in #handleLoopback at index.ts:1545-1563: When #runtimeEntryURL is not set (e.g., during startup), the path matches but response remains undefined, resulting in a 404. This is acceptable since videos can't be served before the runtime starts.

  2. No response status forwarding: When forwarding from loopback to the entry worker, the code at 1557-1562 creates a fetch to the entry URL. The response is then assigned to response. This properly forwards both the body and status code.

  3. The test for preview URL fetchability (line 694): const resp = await fetch(video.preview as string); — This uses Node.js global fetch, which would make a real HTTP request to http://localhost:<loopbackPort>/cdn-cgi/stream/.... The loopback handler forwards this to the runtime entry URL, which then routes to the stream service. This should work correctly.

Let me now check if there are any issues with missing the changeset or any other actionable issues. After thorough review, the code looks correct. The previous Bonk review flagged the remote proxy issue, which was already fixed in the code (the check at line 2213 properly excludes remoteProxyConnectionString). All other Devin issues were false positives.

The code is well-structured. Let me verify one last thing that caught my eye — the as type cast usage in the test when the PR already uses fetch(video.preview as string):

This is fine in test code, and video.preview is typed as string | null in the StreamVideo type, so the as string cast after the expect(video.preview).toBeDefined() check is standard test pattern.

After a thorough review, I don't see any actionable issues (logic bugs, security issues, backward compatibility violations, or incorrect API behavior) that haven't already been flagged by previous reviews. The previous bonk review's suggestion was already incorporated into the code. All other review findings were resolved.

LGTM

github run

@natewong1313 natewong1313 marked this pull request as ready for review April 2, 2026 19:41
@workers-devprod workers-devprod requested review from a team and petebacondarwin and removed request for a team April 2, 2026 19:42
natewong1313 and others added 21 commits April 21, 2026 13:31
…eview URLs

Instead of baking the loopback server address into a static
MF_STREAM_DEV_SERVER_URL binding and relaying /cdn-cgi/mf/stream/
requests through the loopback back into workerd, the stream binding
worker now queries the loopback for the user-facing runtime entry URL
at request time via a new /core/entry-url route.

This is architecturally cleaner because:
- The loopback server is used for its intended purpose (letting workerd
  workers query Node.js for runtime state) rather than as a reverse
  proxy for user-facing content.
- Preview URLs now correctly point at the user-facing entry worker port
  (e.g. http://127.0.0.1:8787) instead of the internal loopback port.
- Requests to preview URLs are handled directly by the entry worker
  without any relay hop.
- Works correctly even with port 0 (auto-assign) since the URL is
  resolved at runtime, not config time.
Add a publicUrl option to Miniflare's shared config that consumers
(Wrangler, Vite plugin) use to provide the stable, externally-reachable
URL of the proxy server in front of Miniflare. This URL outlives
workerd runtime restarts, making stream video preview URLs durable
for the entire dev session.

- Miniflare: publicUrl threaded through CoreSharedOptionsSchema ->
  PluginServicesOptions -> stream plugin -> MF_STREAM_PUBLIC_URL binding
- Wrangler: constructs publicUrl from dev server config (hostname,
  port, secure) in convertToConfigBundle
- Vite plugin: constructs publicUrl from Vite's resolved server config
  in both dev and preview modes
- Binding worker prefers the static publicUrl when set, falls back to
  the /core/entry-url loopback route for direct Miniflare usage
…public-url loopback route

The stream binding now always fetches the public URL from Miniflare's
/core/public-url loopback route instead of reading a static binding.
This allows the Vite plugin to update publicUrl after the server starts
listening (e.g. when the port is bumped) without restarting workerd.

- Add Miniflare.publicUrl public field and /core/public-url loopback route
- Remove MF_STREAM_PUBLIC_URL binding from stream plugin
- Simplify getEntryUrl() in binding.worker.ts to always use loopback
- Add post-listen handler in vite plugin dev/preview to update publicUrl
- Extract buildPublicUrl() helper for shared URL construction logic
- Fix playground port-bump test to pass (was test.fails)
In practice this should never happen as the event that triggers this code always provides a fully filled out `server` object AFAICT.
…t-bump test

On Vite 6/7 on Linux, the default host may resolve to ::1 (IPv6),
which doesn't conflict with a 0.0.0.0 (IPv4) blocker. Explicitly
setting host: "127.0.0.1" on both sides guarantees the port conflict
across all platforms and Vite versions.
@austin-mc
Copy link
Copy Markdown
Contributor

lgtm from the Stream side

@natewong1313 natewong1313 merged commit 7fc50c1 into main Apr 21, 2026
89 of 95 checks passed
@natewong1313 natewong1313 deleted the nwong/STREAM-6745 branch April 21, 2026 18:38
@github-project-automation github-project-automation Bot moved this from Approved to Done in workers-sdk Apr 21, 2026
tewaro pushed a commit that referenced this pull request Apr 21, 2026
This was due to a version mismatch by PR #13615 being merged and later.
PR #13234 had the earlier version of 4.20260420.1 but the version needed
was 4.20260421.1. No tests because this requires workerd CI to test.
tewaro pushed a commit that referenced this pull request Apr 21, 2026
This was due to a version mismatch by PR #13615 being merged and later.
PR #13234 had the earlier version of 4.20260420.1 but the version needed
was 4.20260421.1. No tests because this requires workerd CI to test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

6 participants