Skip to content

feat: proxy media uploads through native delegate for processing#357

Open
dcalhoun wants to merge 11 commits intotrunkfrom
feat/leverage-host-media-processing
Open

feat: proxy media uploads through native delegate for processing#357
dcalhoun wants to merge 11 commits intotrunkfrom
feat/leverage-host-media-processing

Conversation

@dcalhoun
Copy link
Copy Markdown
Member

@dcalhoun dcalhoun commented Mar 9, 2026

What?

Adds a native media upload pipeline that routes file uploads through a local HTTP server on iOS and Android, enabling the host app to process files (e.g., resize images, transcode video) before they are uploaded to WordPress.

Why?

Ref CMM-1249.

Gutenberg's built-in upload path sends files directly from the WebView to the WordPress REST API with no opportunity for native processing. Host apps need to resize images, enforce upload size limits, or apply other transformations before upload. This pipeline gives the native layer full control over media processing while keeping the existing Gutenberg upload UX (blob previews, save locking, entity caching) unchanged.

How?

Architecture: A localhost HTTP server runs on each platform, built on the GutenbergKitHTTP library (iOS) and HttpServer (Android) from #367. The library handles TCP binding, HTTP/1.1 parsing, bearer token authentication (Relay-Authorization), multipart form-data parsing, connection limits, and disk-backed body buffering. The upload server is a thin handler on top.

JS layer:

  • nativeMediaUploadMiddleware in api-fetch.js intercepts POST /wp/v2/media requests when nativeUploadPort is configured in window.GBKit, forwarding files to the local server and transforming responses to WordPress REST API attachment shape (source_url, caption.raw/rendered, title.raw/rendered, media_details, etc.) so the existing Gutenberg upload pipeline works unchanged
  • Uses exact endpoint matching (/wp/v2/media but not /wp/v2/media/123 or /wp/v2/media-categories) to avoid intercepting non-upload requests

Native layer:

  • MediaUploadDelegate protocol/interface with processFile (resize/transcode) and optional uploadFile (custom upload)
  • DefaultMediaUploader as fallback, uploading to /wp/v2/media via the host's HTTP client, with site API namespace support for namespaced sites
  • Upload server only starts when a delegate is provided — no resources wasted otherwise
  • Filename sanitization prevents path traversal from malicious Content-Disposition values
  • User-friendly error messages surfaced to the editor (e.g., "file too large" for 413) with structured error codes (upload_file_too_large, upload_failed)

Demo apps:

  • Both iOS and Android demo apps include a delegate that resizes images to 2000px max
  • "Enable Native Media Upload" toggle (defaults to true) controls whether the delegate is set

Key design decisions

  • Localhost HTTP server over platform-specific bridges: Provides a uniform interception point for all upload paths (file picker, paste, drag-and-drop, programmatic) without requiring per-path bridge wiring. Builds on the cross-platform HTTP library from feat: add cross-platform HTTP/1.1 parser and local proxy server for iOS and Android #367 rather than hand-rolling TCP/HTTP/multipart handling
  • Delegate is opt-in: The server doesn't start and no configuration is injected unless the host provides a MediaUploadDelegate, keeping the default behavior unchanged
  • api-fetch middleware over mediaUpload editor setting: Ideally, media uploads would be handled via the mediaUpload editor setting (see the Gutenberg Framework guides), but GutenbergKit uses Gutenberg's EditorProvider which overwrites that setting internally. Until GutenbergKit is refactored to use BlockEditorProvider, the api-fetch middleware approach is necessary.

Alternatives considered

  1. JS Canvas resize + native inserter resize — Two separate implementations: createImageBitmap() + OffscreenCanvas in JS for web uploads, CGImageSourceCreateThumbnailAtIndex in MediaFileManager.import() for the native inserter. Ships fastest and lowest complexity, but Canvas resize quality is lower than native, two codepaths to maintain, and a dead-end for video (client-side transcoding in a WebView is impractical).

  2. Native upload pipeline via local HTTP server (this PR) — A single api-fetch middleware intercepts all POST /wp/v2/media requests and routes files through a localhost server for native processing. Covers every upload path (file picker, drag-and-drop, paste, programmatic, plugin blocks) with native-quality processing. Scales to video transcoding. More upfront work than option 1.

  3. Replace MediaPlaceholder via withFilters hook — Use editor.MediaPlaceholder and editor.MediaReplaceFlow filters with handleUpload={false} to deliver raw File objects to onSelect, then route to native. Incomplete coverage: misses block drag-and-drop re-uploads (handleBlocksDrop calls mediaUpload directly), direct mediaUpload calls from plugins, and loses blob previews when handleUpload is false. instanceof FileList checks are fragile in WebView contexts.

  4. Redirect to native UI on large files — Keep the web upload button, but show a native dialog when files exceed limits. Awkward UX (user already picked a file, now asked to pick again differently). On iOS, the already-selected JS File can't be handed to native for optimization. Two parallel upload paths add complexity.

  5. JS resize for images + hide web upload for video blocks — JS Canvas resize for images, hide the "Upload" button on video-accepting blocks via editor.MediaPlaceholder filter (forcing users to Media Library for video). Users can't drag-and-drop videos, blocks accepting both image and video (Cover) get complicated, and it's a dead-end architecture.

  6. Client-side processing via @wordpress/upload-media (WASM libvips) — Gutenberg's experimental @wordpress/upload-media package includes a WASM build of libvips for high-quality client-side image resizing, rotation, format transcoding, and thumbnail generation. Quality is comparable to server-side ImageMagick. However, it requires SharedArrayBuffer for WASM threading, which is only available in cross-origin isolated contexts — WKWebView loads GutenbergKit's HTML locally with no HTTP headers, so SharedArrayBuffer is unavailable. WebKit also lacks credentialless iframe support, meaning cross-origin isolation would break third-party embeds (YouTube, Twitter, etc.). Single-threaded WASM fallback is unvalidated, and the package's memory footprint (50-100MB+ per image) is a concern under iOS jetsam pressure. Not viable today, but worth revisiting if the package decouples its store/queue management from WASM processing (tracked upstream).

A key constraint is platform asymmetry: Android can intercept web <input type="file"> via onShowFileChooser(), but iOS cannot — WKWebView handles file selection internally. This rules out purely native interception strategies for web-originated uploads and motivated the localhost server approach, which works identically on both platforms.

Known issues

  • Generic error for uploads exceeding the HTTP library's max request size: When a file exceeds the library's maxRequestBodySize (4 GB default), the library rejects the connection while the browser is still streaming the request body. The WebView's fetch() sees a broken connection and throws a generic network error ("Load failed" on iOS, "Failed to fetch" on Android) instead of a user-friendly message. This is an HTTP/1.1 limitation — the server can't reliably send a 413 response while the client is still writing. Uploads rejected by the WordPress server (with its own limits) do show the server's error message correctly.
  • iOS DefaultMediaUploader loads the entire file into memory: The default uploader reads the file into a Data buffer to build the multipart request body, which could cause memory pressure for large video files under iOS jetsam limits. The Android side avoids this via OkHttp's file.asRequestBody() which streams from disk. This is not a concern for the current image-resize use case (processed images are typically 1–3 MB), but will need a streaming upload approach (e.g., writing the multipart body to a temp file and using URLSession.upload(for:fromFile:)) before video transcoding support is added.

Testing Instructions

  1. Open the iOS or Android demo app connected to a WordPress site (e.g., wp-env)
  2. Verify the "Enable Native Media Upload" toggle is present and defaults to on
  3. Insert an Image block and upload a large image (>2000px)
  4. Verify the upload succeeds and the image displays in the editor
  5. Check logs for "Resized image from WxH to fit 2000px" confirming native processing
  6. Toggle "Enable Native Media Upload" off, restart the editor, and upload again — verify the upload still works (via standard Gutenberg path, no resize log)

Accessibility Testing Instructions

The toggle follows the same pattern as the existing "Enable Native Inserter" toggle — no new UI beyond that.

Screenshots or screencast

N/A — backend/infrastructure change with no visible UI changes beyond the demo app toggle.

@dcalhoun dcalhoun added the [Type] Enhancement A suggestion for improvement. label Mar 9, 2026
@dcalhoun dcalhoun force-pushed the feat/leverage-host-media-processing branch from df05265 to 64d64cb Compare March 9, 2026 17:33
@dcalhoun dcalhoun marked this pull request as ready for review March 10, 2026 01:24
return response.text().then( ( body ) => {
const message =
response.status === 413
? `The file is too large to upload. Please choose a smaller file.`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably print whatever the server sends back – it should be WP_Error-shaped, but some hosts might have messaging like "The max is ${SOME_NUMBER}" or "You've reached your quota".

WDYT?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that makes sense.

This was strictly implemented to handle the 250 MB maximum upload restriction of the local server, but I agree it should be made more robust. If we do not add specific handling for 413, the default user-facing message is something like "Unable to get a valid response from the server."

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 8c78c1a and a3ff56c by removing this JS logic entirely.

@dcalhoun dcalhoun changed the title feat: route media uploads through native host for processing feat: proxy media uploads through native delegate for processing Mar 23, 2026
@dcalhoun dcalhoun force-pushed the feat/leverage-host-media-processing branch from 4cd2cac to 7295a51 Compare March 27, 2026 15:36
dcalhoun and others added 7 commits March 27, 2026 15:12
…dleware

Add nativeMediaUploadMiddleware that intercepts POST /wp/v2/media
requests when a native upload port is configured in GBKit. The
middleware forwards uploads to the local HTTP server with
Relay-Authorization bearer token auth, then transforms the native
response into the WordPress REST API attachment shape.

Includes user-friendly error handling for 413 (file too large) and
generic upload failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add MediaUploadServer backed by the GutenbergKitHTTP library, which
handles TCP binding, HTTP parsing, bearer token auth, and multipart
parsing. The upload server provides a thin handler that routes file
uploads through a native delegate pipeline for processing (e.g. image
resize, video transcode) before uploading to WordPress.

- MediaUploadDelegate protocol with processFile and uploadFile hooks
- DefaultMediaUploader for WordPress REST API uploads with namespace support
- EditorViewController integration with async server lifecycle
- GBKitGlobal nativeUploadPort/nativeUploadToken injection
- GutenbergKitHTTP added as dependency of GutenbergKit target

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add MediaUploadServer backed by the HttpServer library, which handles
TCP binding, HTTP parsing, bearer token auth, and connection management.
The upload server provides a thin handler that routes file uploads
through a native delegate pipeline for processing (e.g. image resize,
video transcode) before uploading to WordPress.

- MediaUploadDelegate interface with processFile and uploadFile hooks
- DefaultMediaUploader for WordPress REST API uploads with namespace support
- GutenbergView integration with synchronous server lifecycle
- GBKitGlobal nativeUploadPort/nativeUploadToken injection
- org.json test dependency added

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add DemoMediaUploadDelegate implementations that resize images to a
maximum dimension of 2000px before upload. Includes a toggle in the
site preparation screen to enable/disable native media upload
processing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Integration tests covering server lifecycle, bearer token auth (407 on
missing/wrong token), CORS preflight, routing (404 for unknown paths),
delegate processing pipeline, and fallback to default uploader.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Integration tests covering server lifecycle, bearer token auth (407 on
missing/wrong token), CORS preflight, routing (404 for unknown paths),
delegate processing pipeline, fallback to default uploader,
DefaultMediaUploader request format, and error handling for bad
requests and non-multipart content types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests covering passthrough behavior (missing port, non-POST, non-media
paths, sub-paths, non-FormData), upload interception with
Relay-Authorization auth, response transformation to WordPress REST API
shape, error handling (413 file too large, generic failures), and abort
signal forwarding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@dcalhoun dcalhoun force-pushed the feat/leverage-host-media-processing branch from 5a78344 to 18f81b7 Compare March 27, 2026 19:27
dcalhoun and others added 4 commits March 27, 2026 15:55
Add LocalizedError conformance to EditorHTTPClient.ClientError so that
WordPress error messages (e.g. "This file is too large. The maximum
upload size is 10 KB.") are surfaced to the user instead of a cryptic
Swift type description.

Remove the dead 413-specific handling from the JS middleware — the HTTP
library rejects oversized uploads at the connection level (never
producing an HTTP response the browser can read), so the 413 branch
was unreachable. All upload errors now go through the generic path
which includes the server's error message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ponses

Parse the WordPress JSON error body to extract the message field (e.g.
"This file is too large. The maximum upload size is 10 KB.") instead
of showing the raw JSON in the upload failure snackbar. Falls back to
the raw body for non-JSON error responses.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Compose corsPreflightResponse() from the shared corsHeaders constant
instead of re-declaring origin and allowed-headers values. Also removes
a redundant "what" comment on the multipart parsing call.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ment

Guard the mediaUploadDelegate setter with an identity check so that
assigning the same delegate instance does not needlessly stop and
restart the upload server.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
}

func upload(fileURL: URL, mimeType: String, filename: String) async throws -> MediaUploadResult {
let fileData = try Data(contentsOf: fileURL)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Known issue: This loads the entire file into memory to build the multipart body. Fine for the image-resize use case, but we may need a streaming approach for videos.

A fix might be to write the multipart body to a temp file on disk and upload via URLSession.upload(for:fromFile:). That requires either adding an upload(_:fromFile:) method to EditorHTTPClientProtocol or having DefaultMediaUploader own a URLSession directly. Android already avoids this via OkHttp's file.asRequestBody().

Noted in the PR description's "Known issues" section.

link: result.url,
};
} )
.catch( ( err ) => {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Known issue (non-blocking): When a file exceeds the HTTP library's maxRequestBodySize (4 GB default), the library rejects the TCP connection while the browser is still streaming the request body. The fetch() throws a generic TypeError ("Load failed" / "Failed to fetch") that lands here — not the structured error from the .then branch above.

There's no good workaround at this layer — the server can't reliably send an HTTP 413 response while the client is still writing (HTTP/1.1 limitation). Uploads rejected by WordPress's own size limits do produce a proper error response that hits the !response.ok branch correctly.

Noted in the PR description's "Known issues" section.

@dcalhoun
Copy link
Copy Markdown
Member Author

@jkmassel this now relies upon the GBK HTTP server library. This is ready for another review.

@dcalhoun dcalhoun requested a review from jkmassel March 27, 2026 21:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Type] Enhancement A suggestion for improvement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants