Skip to content

feat: route media uploads through native host for processing#357

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

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

Conversation

@dcalhoun
Copy link
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 (NWListener on iOS, raw ServerSocket on Android), bound to 127.0.0.1 on a random port with per-session Bearer token auth.

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
  • Upload server only starts when a delegate is provided — no resources wasted otherwise
  • Filename sanitization prevents path traversal from malicious Content-Disposition values
  • 250 MB upload size limit with early rejection (HTTP 413) before buffering the body into memory
  • 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
  • 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.

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 added 18 commits March 6, 2026 14:52
…peline

Add JS layer for routing file uploads through a native host's local HTTP
server. When `nativeUploadPort` is present in GBKit config, the override
activates — creating blob previews for immediate block feedback, locking
post saving during upload, POSTing files to localhost with Bearer auth,
updating the entity cache on success, and cleaning up on failure. When
the config is absent, default Gutenberg upload behavior is preserved.
Add MediaUploadServer (NWListener-based) that receives file uploads from
the WebView and routes them through the native media processing pipeline.
Includes MediaUploadDelegate protocol for host app customization,
DefaultMediaUploader for WordPress REST API uploads, and GBKitGlobal
config plumbing for port/token injection.
Add MediaUploadServer (ServerSocket-based) that receives file uploads from
the WebView and routes them through the native media processing pipeline.
Includes MediaUploadDelegate interface for host app customization,
DefaultMediaUploader using OkHttp for WordPress REST API uploads, and
GBKitGlobal/GutenbergView wiring for server lifecycle and config injection.
Return nil from uploadFile to fall back to the default uploader. This
allows delegates that only need to customize file processing (e.g.,
image resize) without reimplementing the upload step.
Both demo apps now set a MediaUploadDelegate that resizes images to a
maximum dimension of 2000px before upload. Serves as a reference
implementation for WordPress app integration.
Integration tests (auth, CORS, routing, delegate flow, default uploader
fallback) are conditionally skipped when NWListener cannot bind in the
test sandbox. Unit tests cover delegate defaults and result encoding.
Don't signal the startup semaphore on .waiting state — it's transient
and the listener may still transition to .ready. This prevents the
server from aborting prematurely in environments where the loopback
path takes a moment to become available.
NWListener requires newConnectionHandler to be set before calling
start(), otherwise it fails with EINVAL. Use a mutable reference box
to work around the Swift init ordering constraint (self not available
until all stored properties are assigned).
Fix the multipart boundary parser which never captured content between
the first and closing boundary markers. Rewrite to find all `--boundary`
positions, then extract parts between consecutive positions.

Use POSIX sockets in upload integration tests to bypass URLSession's
resumable upload protocol framing (draft-ietf-httpbis-resumable-upload)
on iOS 26+, which prepends extra bytes that break multipart parsing.
Production traffic from WebView fetch() is unaffected.

Also improve isRequestComplete to handle chunked transfer encoding
and add chunked body decoding in HTTPRequest.
Replace the ineffective mediaUpload settings override with an apiFetch
middleware that intercepts POST /wp/v2/media requests. The settings-based
approach was being overwritten by EditorProvider's useBlockEditorSettings
hook on every render.

The new nativeMediaUploadMiddleware in api-fetch.js:
- Intercepts media upload requests when nativeUploadPort is configured
- Forwards the file to the native local HTTP server for processing
- Transforms the response into WordPress REST API attachment format
- Preserves the full Gutenberg upload UX (blob previews, save locking)

Also fix HTTPRequest parser to search for the header/body separator in
raw bytes instead of converting the entire request to UTF-8, which
failed when the body contained binary data (e.g. JPEG).
Match the current iOS demo app.
The MediaUploadServer was created during GutenbergView's constructor,
before the mediaUploadDelegate property was assigned in the caller's
apply block. This caused uploadDelegate to be null, so processFile
was never called.

Move startUploadServer() into the mediaUploadDelegate setter so it
always captures the delegate. Also revert the upload URL from
127.0.0.1 to localhost, and add Access-Control-Allow-Private-Network
header to CORS preflight responses on both platforms.
This CORS header was added during debugging but isn't needed — uploads
work without it on both iOS and Android.
Mirror the iOS test suite with integration tests for the local HTTP
server (auth, CORS, routing, multipart upload, delegate fallback) and
unit tests for MediaUploadDelegate defaults and DefaultMediaUploader.

Make DefaultMediaUploader open so the fallback test can mock it without
pulling in org.json (which is stubbed in JVM unit tests).
Test the apiFetch middleware that intercepts POST /wp/v2/media requests
and routes them through the native upload server. Covers passthrough
conditions, response transformation, error handling, and signal
forwarding.

Export the middleware function for direct test access.
@dcalhoun dcalhoun added the [Type] Enhancement A suggestion for improvement. label Mar 9, 2026
dcalhoun and others added 10 commits March 9, 2026 09:37
Add UUID prefix to temp file names on both iOS and Android to prevent
concurrent uploads of files with the same name from overwriting each
other.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Create a single lazy OkHttpClient instance instead of allocating a new
one each time startUploadServer() is called, enabling connection pooling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When mediaUploadDelegate is set multiple times, the old server socket
was left open. Now explicitly stop the previous server before starting
a new one.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace manual string interpolation with escapeJson() with
org.json.JSONObject for proper JSON encoding, handling all special
characters including forward slashes and Unicode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Document the brief window where incoming connections could be dropped
between listener.start() and serverRef assignment, and why it is safe
in practice.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove emoji characters from print/NSLog statements in the demo app
and framework code per project conventions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The uploadFile function in native-upload.js was returning the native
server's flat response format directly. Gutenberg blocks expect the
WordPress REST API attachment shape (source_url, mime_type, title.rendered,
etc.). Apply the same transformation used in the api-fetch middleware.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reject oversized uploads early on both iOS and Android to prevent
out-of-memory conditions from malicious or buggy requests that would
otherwise be read entirely into memory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Delete both the original temp file and the processed file (if different)
after upload completes, whether it succeeds or fails. Prevents temp file
accumulation on devices with heavy upload usage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use safe-call operator with a descriptive RuntimeException instead of
force-unwrapping the OkHttp response body.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dcalhoun and others added 13 commits March 9, 2026 10:37
The native-upload module (`createNativeMediaUpload`) was superseded by
the `nativeMediaUploadMiddleware` api-fetch middleware approach and is no
longer imported by any production code.

Remove the module and its tests, clean up stale references in
editor.test.jsx, and document in api-fetch.js why the middleware
approach is used instead of the preferred `mediaUpload` editor setting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use a regex to match only `/wp/v2/media` (with optional query string)
instead of `startsWith`, which would also match sub-paths like
`/wp/v2/media/123` or `/wp/v2/media-categories`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add comments to iOS and Android upload servers explaining:
- Full in-memory buffering means ~500 MB heap for a 250 MB upload;
  streaming would complicate multipart parsing, and the size cap
  mitigates the risk.
- Android's runBlocking on a CachedThreadPool won't deadlock since
  threads are created on demand, acceptable for 1-2 concurrent uploads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This was accidentally changed and would cause verbose JS console
output in all Android builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Align upload server Logger subsystem to "GutenbergKit" matching all
other loggers in EditorLogging.swift. Remove unused
ServerError.payloadTooLarge case (413 is returned directly).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Headers were re-parsed on every 64 KB chunk, making the receive loop
O(n²) in total bytes. Parse once into a ParsedHeaderInfo struct and
reuse it for subsequent completeness checks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
readHttpRequest previously returned null when Content-Length exceeded
MAX_UPLOAD_SIZE, which surfaced as a generic 400 "Malformed HTTP
request". Now it returns a valid HttpRequestData with the declared
content length preserved, so handleUpload produces the proper 413
"Payload Too Large" response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Consolidate all Logger category definitions in one file for
discoverability, consistent with timing, assetLibrary, http, and
navigation categories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add TAG constant to GutenbergView companion object instead of
  hardcoded string in Log.w call.
- Use strict regex in mediaUploadMiddleware to match exact /wp/v2/media
  endpoint, consistent with nativeMediaUploadMiddleware. The previous
  startsWith check would false-match paths like /wp/v2/media-categories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
isRequestComplete short-circuits the body read when Content-Length
exceeds maxUploadSize, leaving a truncated body. handleUpload then
checked body.count (which was small) instead of the declared
Content-Length, so multipart parsing failed with a misleading 400
"No file found in request". Now checks the Content-Length header
to correctly return 413 "Payload Too Large".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Instead of the raw "Native upload failed (413): Upload exceeds
maximum allowed size", display "The file is too large to upload.
Please choose a smaller file." with a distinct error code
(upload_file_too_large) so host apps can handle it specifically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Strip path separators and directory components from multipart
Content-Disposition filenames before writing to the temp directory.
A malicious filename like "../../etc/passwd" could otherwise escape
the upload directory. Flagged by CodeQL as "Uncontrolled data used
in path expression" (high severity).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
MediaUploadServer uses org.json.JSONObject to serialize upload
responses. In JVM unit tests the Android framework stub returns null
from toString(), causing "toString(...) must not be null". Adding
org.json:json as a testImplementation provides a real implementation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dcalhoun dcalhoun force-pushed the feat/leverage-host-media-processing branch from df05265 to 64d64cb Compare March 9, 2026 17:33
dcalhoun and others added 13 commits March 9, 2026 13:42
After replacing the `native-upload` module with `api-fetch` middleware,
these changes became unnecessary.
…pload server

WKWebView's fetch() with FormData always sends Content-Length — verified
by testing uploads of small images, large images, and video on iOS 18.
This removes ~40 lines of dead code (chunked detection, decoding, and
the Data.hasSuffix helper), aligning the iOS server with the simpler
Android implementation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The `/wp/v2/media` path regex was duplicated in nativeMediaUploadMiddleware
and mediaUploadMiddleware. Extract to a shared MEDIA_UPLOAD_PATH constant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace `string.data(using: .utf8)!` with `Data(string.utf8)` which
cannot fail, preventing a potential crash with unusual filenames.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The upload tests used raw POSIX sockets to work around a suspected
URLSession resumable upload protocol issue. Testing confirms
URLSession.shared.data(for:) works fine for POST requests with
multipart bodies — the resumable protocol only applies to upload tasks.

This removes ~90 lines of low-level socket code, the Darwin import,
and the TestUploadError type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The upload method now validates the HTTP status code before attempting
to decode the response body. Non-success responses (4xx/5xx) throw a
descriptive error including the status code and response preview.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add width and height fields to MediaUploadResult on both iOS and
Android, parsed from the WordPress REST API media_details object.
The JS middleware now includes media_details in the transformed
response, which some Gutenberg blocks (e.g., Image) use for
responsive layout calculations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Align MediaUploadDelegate.swift and MediaUploadServer.swift with the
project's 4-space indentation convention for Swift files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
iOS: replace print() with os.Logger for consistent structured logging.
Android: use companion TAG constant instead of string literal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace NSLog with Logger.uploadServer for consistency with the rest of
the upload server code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove 6 tests that validate language features rather than application
logic: 2 Swift Codable round-trip/encoding tests (compiler-validated),
and 4 protocol default tests (trivial one-line defaults already
exercised by integration tests).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@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
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
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."

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