feat: route media uploads through native host for processing#357
Open
feat: route media uploads through native host for processing#357
Conversation
…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.
android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt
Fixed
Show fixed
Hide fixed
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>
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>
df05265 to
64d64cb
Compare
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>
jkmassel
reviewed
Mar 10, 2026
| return response.text().then( ( body ) => { | ||
| const message = | ||
| response.status === 413 | ||
| ? `The file is too large to upload. Please choose a smaller file.` |
Contributor
There was a problem hiding this comment.
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?
Member
Author
There was a problem hiding this comment.
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."
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.1on a random port with per-session Bearer token auth.JS layer:
nativeMediaUploadMiddlewareinapi-fetch.jsinterceptsPOST /wp/v2/mediarequests whennativeUploadPortis configured inwindow.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/wp/v2/mediabut not/wp/v2/media/123or/wp/v2/media-categories) to avoid intercepting non-upload requestsNative layer:
MediaUploadDelegateprotocol/interface withprocessFile(resize/transcode) and optionaluploadFile(custom upload)DefaultMediaUploaderas fallback, uploading to/wp/v2/mediavia the host's HTTP clientContent-Dispositionvaluesupload_file_too_large,upload_failed)Demo apps:
Key design decisions
MediaUploadDelegate, keeping the default behavior unchangedapi-fetchmiddleware overmediaUploadeditor setting: Ideally, media uploads would be handled via themediaUploadeditor setting (see the Gutenberg Framework guides), but GutenbergKit uses Gutenberg'sEditorProviderwhich overwrites that setting internally. Until GutenbergKit is refactored to useBlockEditorProvider, theapi-fetchmiddleware approach is necessary.Alternatives considered
JS Canvas resize + native inserter resize — Two separate implementations:
createImageBitmap()+OffscreenCanvasin JS for web uploads,CGImageSourceCreateThumbnailAtIndexinMediaFileManager.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).Native upload pipeline via local HTTP server (this PR) — A single
api-fetchmiddleware intercepts allPOST /wp/v2/mediarequests 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.Replace
MediaPlaceholderviawithFiltershook — Useeditor.MediaPlaceholderandeditor.MediaReplaceFlowfilters withhandleUpload={false}to deliver rawFileobjects toonSelect, then route to native. Incomplete coverage: misses block drag-and-drop re-uploads (handleBlocksDropcallsmediaUploaddirectly), directmediaUploadcalls from plugins, and loses blob previews whenhandleUploadis false.instanceof FileListchecks are fragile in WebView contexts.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
Filecan't be handed to native for optimization. Two parallel upload paths add complexity.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.MediaPlaceholderfilter (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.Client-side processing via
@wordpress/upload-media(WASM libvips) — Gutenberg's experimental@wordpress/upload-mediapackage 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 requiresSharedArrayBufferfor WASM threading, which is only available in cross-origin isolated contexts — WKWebView loads GutenbergKit's HTML locally with no HTTP headers, soSharedArrayBufferis unavailable. WebKit also lackscredentiallessiframe 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">viaonShowFileChooser(), but iOS cannot —WKWebViewhandles 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
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.