diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 07ab5aa00..c018124f7 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -74,7 +74,7 @@ jobs:
languages: swift
- name: Build Swift package
- run: swift build --target GutenbergKit --target GutenbergKitHTTP
+ run: swift build --target GutenbergKit
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
diff --git a/AGENTS.md b/AGENTS.md
index fa86dd388..3bcfd18e9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,7 +11,7 @@ GutenbergKit is a Gutenberg block editor for native iOS and Android apps built w
- Kotlin library for Android integration
- Native-to-web bridge for communication between platforms
-For deeper architectural context on specific subsystems, see the docs under `docs/code/` — including `architecture.md`, `plugins.md`, `preloading.md`, and others.
+For deeper architectural context on specific subsystems, see the docs under `docs/code/` — including `architecture.md`, `media-uploads.md`, `plugins.md`, `preloading.md`, and others.
## Common Development Commands
diff --git a/Package.swift b/Package.swift
index 12c5443ed..d9c69ecbe 100644
--- a/Package.swift
+++ b/Package.swift
@@ -17,7 +17,6 @@ let package = Package(
platforms: [.iOS(.v17), .macOS(.v14)],
products: [
.library(name: "GutenbergKit", targets: ["GutenbergKit"]),
- .library(name: "GutenbergKitHTTP", targets: ["GutenbergKitHTTP"]),
.library(name: "GutenbergKitResources", targets: ["GutenbergKitResources"]),
],
dependencies: [
@@ -27,21 +26,10 @@ let package = Package(
targets: [
.target(
name: "GutenbergKit",
- dependencies: ["SwiftSoup", "SVGView", "GutenbergKitResources", "GutenbergKitHTTP"],
+ dependencies: ["SwiftSoup", "SVGView", "GutenbergKitResources"],
path: "ios/Sources/GutenbergKit",
packageAccess: false
),
- .target(
- name: "GutenbergKitHTTP",
- path: "ios/Sources/GutenbergKitHTTP",
- exclude: ["README.md"]
- ),
- .executableTarget(
- name: "GutenbergKitDebugServer",
- dependencies: ["GutenbergKitHTTP"],
- path: "ios/Sources/GutenbergKitDebugServer",
- exclude: ["README.md"]
- ),
gutenbergKitResources,
.testTarget(
name: "GutenbergKitTests",
@@ -52,14 +40,6 @@ let package = Package(
.process("Resources")
]
),
- .testTarget(
- name: "GutenbergKitHTTPTests",
- dependencies: ["GutenbergKitHTTP"],
- path: "ios/Tests/GutenbergKitHTTPTests",
- resources: [
- .copy("../../../test-fixtures/http")
- ]
- ),
]
)
diff --git a/docs/code/local-wordpress.md b/docs/code/local-wordpress.md
index be4069b22..892ae83f0 100644
--- a/docs/code/local-wordpress.md
+++ b/docs/code/local-wordpress.md
@@ -140,7 +140,7 @@ The mode is stored server-side, so it persists across uploads and retries until
Then upload an image from a demo app and watch the network requests. In `recover` mode the upload 500s and the following `post-process` call succeeds, leaving a complete attachment; in `always` mode you should see five `post-process` attempts followed by a `DELETE`.
-**Only the native upload server path recovers locally.** Reading `X-WP-Upload-Attachment-ID` cross-origin requires the site to list it in `Access-Control-Expose-Headers`, and WordPress core's `rest_send_cors_headers()` does not. Uploads routed through the native upload server recover on both platforms, since that server exposes the header itself.
+**Only native uploads recover locally.** Reading `X-WP-Upload-Attachment-ID` cross-origin requires the site to list it in `Access-Control-Expose-Headers`, and WordPress core's `rest_send_cors_headers()` does not. Native uploads recover on both platforms, since native code relays the header and exposes it itself — the `gbk-upload:` scheme on iOS, the loopback server on Android.
A **direct** upload (native media upload disabled) never recovers on iOS, which loads the editor from `file://`. It does not recover against wp-env on Android either: `GutenbergView` derives the asset domain from the site's _host_, which drops the port, so the editor at `http://10.0.2.2` is cross-origin with the site at `http://10.0.2.2:8888`. Direct uploads are only same-origin — and therefore only recover — when the site runs on the scheme's default port, as production sites do.
diff --git a/docs/code/media-uploads.md b/docs/code/media-uploads.md
new file mode 100644
index 000000000..566212c37
--- /dev/null
+++ b/docs/code/media-uploads.md
@@ -0,0 +1,129 @@
+# Media Uploads
+
+How a media upload gets from the editor to WordPress when the host supplies a
+`MediaProcessor` or `MediaUploader`. See [Integration](../integration.md#media-handling)
+for the host-facing API.
+
+## Where uploads come from
+
+| Source | Reaches native code as |
+| ------------------------------------------------------------ | ----------------------------------------------------------- |
+| Upload button, drag-and-drop, paste, "upload external image" | a `File` in the page, sent by `nativeMediaUploadMiddleware` |
+| The native block inserter (iOS) | a `File` native code hands the page, sent the same way |
+
+Both end in the same place. Core's `mediaUpload` builds a `FormData` and calls
+`apiFetch({ path: '/wp/v2/media', method: 'POST' })`. `nativeMediaUploadMiddleware`
+(`src/utils/api-fetch.js`) intercepts that and hands the upload to native code. Native code
+returns WordPress's response, and core finishes the job: it replaces the placeholder,
+releases the save lock, and shows errors.
+
+Core's own upload middleware sits above ours and always asks for `parse: false`. It reads
+`x-wp-upload-attachment-id` off a failed response to retry `post-process`, so native code
+relays that header, and every native response exposes it under CORS. The orphan `DELETE`
+core sends when recovery fails is relayed natively too: a cross-origin editor can't send it
+itself.
+
+## Transports
+
+The middleware picks one from what the host advertises in `GBKit`:
+
+- **iOS: `nativeUploadScheme`** (`gbk-upload`), served by `MediaUploadSchemeHandler`.
+- **Android: `nativeUploadPort` and `nativeUploadToken`**, a loopback HTTP server
+ (`HttpServer.kt`).
+
+With neither, requests pass through and the page uploads straight to WordPress.
+
+### iOS: the `gbk-upload:` scheme
+
+A `WKURLSchemeHandler` in the editor's own web view. It has no socket, so iOS can't reclaim
+it when a suspended app's device idle-sleeps, which is what broke the loopback server
+after an ordinary screen lock. There is no token either: only this web view can load the
+scheme.
+
+WebKit hands a scheme handler only bodies it has buffered. Measured on iOS 27 with
+Lockdown Mode:
+
+| `fetch` body | Reaches the handler |
+| ----------------------------------------------------- | ---------------------------------------------- |
+| string, `URLSearchParams`, `ArrayBuffer` | yes, as `httpBody` |
+| an in-memory `Blob`/`File`, or `FormData` holding one | **no body at all**, and `fetch` still succeeds |
+| a `File` from the photo picker, in `FormData` | as `httpBodyStream` |
+| a dropped `File`, in `FormData` | **no body at all** |
+
+The streamed case depends on where the file came from, and every failure is silent, so
+the page always sends the file as 4 MB `ArrayBuffer` chunks:
+
+| Request | Body | Response |
+| -------------------------------------- | ---------------------------- | -------------------- |
+| `POST gbk-upload://upload/sessions` | `{filename, mimeType, size}` | `201 {"id"}` |
+| `POST …/sessions//chunks?offset=N` | the chunk | `200 {"received"}` |
+| `POST …/sessions//finish` | `{fields, query}` | WordPress's response |
+| `POST …/sessions//cancel` | — | `204` |
+| `POST …/media//delete` | `{query}` | WordPress's response |
+
+`MediaUploadSessionStore` writes each chunk straight to a staging file and refuses one at
+the wrong offset. A 1.1 GB upload peaked at 44 MB of app memory.
+
+- **Fallback.** A failure before `finish` means WordPress never saw the file, so the page
+ uploads through the web view instead. From `finish` on it doesn't retry: native code
+ may already have sent the file.
+- **Stopped tasks.** A task WebKit stops (the page aborted, or went away) is never
+ answered — answering one raises — and its upload to WordPress is cancelled.
+- **Holding `finish`.** `finish` stays open while WordPress processes the upload. WebKit
+ held one for 26 minutes in the foreground, and for hours across app suspension, and
+ delivered the result.
+- **Disabled.** After `stopMediaHandling()` every request gets a `503`, which the page
+ takes as the cue to fall back.
+
+### Native inserter media
+
+The inserter imports a picked photo or video as a file. On APFS that copy is a clone, so
+an import of any size costs no memory. The page then needs it as a `File`: Gutenberg's
+upload pipeline reads the bytes from one, and so does a block that uploads on its own
+(VideoPress sends its file to its own endpoint and never calls `mediaUpload`).
+
+The page clicks a hidden file input (`requestNativeFiles` in `src/utils/native-files.js`),
+and `NativeFileInput` answers the open panel WebKit would otherwise show with the imported
+files. The page gets what the system picker gives it: `File`s that WebKit reads from disk
+as they are sliced. From there an inserter pick is an Upload-button pick. On an iPhone 14
+Pro (iOS 18.6.2) the page read a 1.1 GB video through 4 MB slices in about a second,
+byte for byte.
+
+- **iOS 18.4.** WebKit asks its UI delegate for the panel from iOS 18.4. Before that the
+ inserter hides the photo library and the camera, and media is added from a block's own
+ upload button.
+- **Only while offering.** A UI delegate that implements the panel answers every file
+ input, so `NativeFileInput` is the web view's UI delegate only for the insertion, and
+ puts the host's delegate back.
+- **User activation.** The click needs the user activation the native script call
+ carries, so the page asks for the files before its first `await`.
+- **Fallback.** If the files don't arrive, the page fetches them from `gbk-media-file:`
+ instead, which holds each file in the page's memory.
+- **WebKit's copies.** WebKit copies every file a file input receives into
+ `tmp/WKFileUploadPanel-…` (a clone) and never deletes it. `MediaFileManager` removes
+ the ones older than two days, along with its own imports.
+
+## Background and timeouts
+
+- An upload holds a `performExpiringActivity` assertion, which keeps the app running for
+ about 30 seconds after it leaves the foreground. A longer upload is interrupted when iOS
+ suspends the app.
+- A background `URLSession` would survive suspension. GutenbergKit doesn't use one: when
+ WordPress was slow to answer, `nsurlsessiond` re-sent the whole upload about every 100
+ seconds, and each copy became an attachment. A host `MediaUploader` that uses one has to
+ dedupe.
+- Uploads get a 10-minute inactivity timeout (`EditorHTTPClient.uploadInactivityTimeout`).
+ URLRequest's 60-second default fired while WordPress generated image sizes, and left
+ the attachment behind.
+
+## Tests
+
+- JS: `src/utils/api-fetch-upload-scheme.test.js`, `api-fetch-post-process.test.js` (core's
+ recovery over the scheme), `native-files.test.js`, and
+ `api-fetch-upload-middleware.test.js` (the Android loopback transport).
+- Swift, on the host: `MediaUploadSchemeHandlerTests`, `MediaUploadSessionStoreTests`,
+ `MediaUploadServiceTests`, `InternalMediaClientTests`, `MediaFileSchemeHandlerTests`,
+ `MediaImportTests`, `NativeFileInputTests`.
+- Swift, in the simulator: `EditorViewControllerMediaTeardownTests` runs the upload
+ protocol in the editor's own `WKWebView`, and has a page's file input receive a file
+ from `NativeFileInput`.
diff --git a/docs/code/preloading.md b/docs/code/preloading.md
index e00795a56..e56a717a8 100644
--- a/docs/code/preloading.md
+++ b/docs/code/preloading.md
@@ -84,6 +84,8 @@ The `EditorURLCache` provides disk-based caching for API responses, keyed by URL
| `.maxAge(TimeInterval)` | Use cached responses younger than the specified age |
| `.always` | Always use cached responses regardless of age |
+The same policy decides when an `EditorService` checks for new plugin and theme assets; see [Refreshing](#refreshing).
+
Example:
**Swift**
@@ -211,16 +213,20 @@ This filtering is performed by `EditorURLResponse.asPreloadResponse()`.
### Automatic Cleanup
-`EditorService` automatically cleans up old asset bundles once per day:
+`EditorService` automatically cleans up each site's old asset bundles once per day:
**Swift**
```swift
-try await onceEvery(.seconds(86_400)) {
- try await self.cleanup()
-}
+try await onceEvery(
+ .seconds(86_400),
+ { try await self.cleanup() },
+ handle: "asset-bundle-cleanup-\(self.configuration.siteId)"
+)
```
+A cleanup keeps the site's latest bundle, and any bundle the app has been handed since it launched — an open editor, or dependencies the host still holds, may be reading it.
+
**Kotlin**
```kotlin
@@ -245,6 +251,36 @@ try await service.purge()
//tbd
```
+### Refreshing
+
+An `EditorService`'s cache policy covers plugin and theme assets as well as API responses. For assets, it decides when to check the site's asset manifest again:
+
+| Policy | API responses | Asset bundle |
+| ----------------------- | ------------------------------- | ---------------------------------------------------------- |
+| `.always` (default) | Fetched only when not cached | Manifest checked only when no bundle is on disk |
+| `.maxAge(TimeInterval)` | Fetched once older than the age | Manifest checked once the last check is older than the age |
+| `.ignore` | Always fetched | Manifest always checked |
+
+If the manifest hasn't changed, the bundle on disk is kept rather than downloaded again — asset URLs carry their version (`?ver=`), so the same manifest means the same assets — and its age starts over. Only an asset that failed to download when the bundle was built is tried again. If the manifest has changed, the new bundle is built beside the old one, and every service for the site uses it once it's complete.
+
+The old bundle stays on disk for as long as the app is running, because an open editor — or dependencies the host prepared earlier and still holds — may be reading it. `cleanup()` removes it after the next launch.
+
+To refresh a site's editor data — on pull-to-refresh, for instance — prepare a separate service that ignores the cache, and give its dependencies to the next editor:
+
+**Swift**
+
+```swift
+let dependencies = try await EditorService(configuration: configuration, cachePolicy: .ignore).prepare()
+```
+
+Nothing is deleted first, so an editor opened during the refresh still loads straight from what's on disk, and a refresh that fails leaves it all in place. An editor given no dependencies prepares its own with `.always`, so it uses whatever the last refresh left. To download assets again even when their manifest hasn't changed, `purge()` instead, at the cost of a cold load for the next editor.
+
+A refresh that can't reach the site throws. If the configuration's `networkFallbackMode` is `.automatic`, it returns the dependencies already on disk instead — however old they are — so they're still safe to give to the next editor. It returns empty dependencies only when something the editor needs has never been cached.
+
+**Kotlin**
+
+Not yet: Android's `EditorService` still checks the asset manifest only when no bundle is on disk, whatever its cache policy.
+
## Offline Mode
When `EditorConfiguration.isOfflineModeEnabled` is `true`, the preloading system returns empty dependencies:
@@ -280,6 +316,8 @@ let config = EditorConfigurationBuilder(
When a network error is caught (e.g., `notConnectedToInternet`, `timedOut`, `cannotConnectToHost`), `EditorService.prepare()` returns empty dependencies — the same as offline mode — so the bundled editor loads instead of showing an error. Non-network errors (e.g., decoding failures) still propagate normally.
+On iOS, a service whose cache policy is `.maxAge` or `.ignore` first falls back to the dependencies already on disk, however old: they can't be checked against a site that can't be reached, and they're better than none. It returns empty dependencies only if some are missing.
+
On the JavaScript side, an `OfflineIndicator` component displays a "Working Offline" status bar at the top of the editor when the device loses connectivity. The indicator automatically appears and disappears based on the browser's `online`/`offline` events.
| Mode | Use case | Behavior |
@@ -313,6 +351,28 @@ let dependencies = try await service.prepare { progress in
}
```
+#### Sharing Work Between Services
+
+Every `EditorService` for a site reads and writes the same on-disk caches, so there's no need to hand a service from a
+prefetch to the editor — create one for each caller. Don't call `prepare()` on a service while an earlier call on it is
+still running: progress is tracked per service, so the later call takes over the progress callback, and whichever
+finishes first stops progress for both.
+
+Services for the same site also share work while it's in flight. A request identical to one already in flight joins it
+rather than going out again, and a build of an asset bundle joins the one already running. So an editor opened before a
+prefetch finishes fetches only its own post and the `editor-assets` manifest, even when the two are for different posts.
+Requests are shared only between clients with the same `URLSession` instance, credentials, and timeout, and never from a
+client with a delegate, which expects to see every request it makes. A bundle build is shared by every service for the
+site whatever its client, just as the bundle it produces is once it's on disk.
+
+The request for the post is never shared, even between two editors on the same post: one already in flight can predate
+an edit made since. It opts out through its cache policy — a request that asks to skip the cache
+(`.reloadIgnoringLocalCacheData` and its siblings) always goes out on its own — and a host's own requests through
+`EditorHTTPClient` can do the same.
+
+Cancelling a caller ends only that caller's wait; shared work stops once no caller is left waiting on it. `purge()`
+doesn't stop it, so work that began before a purge can still land after it.
+
### EditorViewController Loading Flows
`EditorViewController` supports two loading flows based on whether dependencies are provided:
@@ -337,7 +397,9 @@ let editor = EditorViewController(
)
```
-The editor displays a progress bar while fetching, then loads once complete.
+The editor displays a progress bar while fetching, then loads once complete. The fetch does not hold the
+editor: releasing it mid-fetch frees it immediately, and the fetch finishes in the background, warming the
+cache for the next editor.
### Best Practice: Prepare Early
diff --git a/docs/integration.md b/docs/integration.md
index 872360e7f..37c4dc521 100644
--- a/docs/integration.md
+++ b/docs/integration.md
@@ -274,7 +274,7 @@ if you put it there.
That happens when you conform the object that already holds the editor in order to drive
it. The editor holds the processor strongly in return — deliberately, so an in-flight upload
can't lose it mid-request — which closes a retain cycle ARC cannot break. The editor is
-never deallocated, and each one strands a bound loopback listener.
+never deallocated, and on Android each one also strands a bound loopback listener.
```swift
// Leaks: coordinator -> editor -> mediaProcessor -> coordinator
@@ -307,14 +307,40 @@ are finished with the editor. It is terminal — the editor cannot upload or del
afterwards — so call it when the editor is going away, not when it is merely covered or
backgrounded.
+### How uploads reach native code
+
+A native upload runs your `MediaProcessor`, then your `MediaUploader` or GutenbergKit's
+own client, whichever way the file arrived:
+
+- **Files the page holds** — the Upload button, drag-and-drop, paste — go from the editor's
+ page to native code. On iOS that is a URL scheme the editor's own web view handles, so
+ there is nothing to configure and it works under Lockdown Mode. On Android it is a
+ loopback HTTP server, which needs the network security entry below.
+- **Media from the native block inserter** is imported to disk (a clone, on iOS, so a
+ large video costs no memory) and handed to the page as a file, which it then uploads
+ like any other. WebKit reads the file from disk as it is sent, so the page never holds
+ it in memory. This needs iOS 18.4: before that the inserter doesn't offer the photo
+ library or the camera, and media is added from a block's own upload button.
+
+Either way the block uploads through Gutenberg's own pipeline, so its placeholder, saving
+lock, error notices, and recovery of a failed server-side resize all behave as they do for
+any upload. If native uploads are unavailable — no handler, no site credentials, or after
+`stopMediaHandling()` — the page uploads straight to WordPress instead.
+
+An upload keeps running for about 30 seconds after the app moves to the background. One
+that takes longer is interrupted when iOS suspends the app. A `MediaUploader` that needs to
+survive that should use a background `URLSession` of its own, and dedupe by filename or a
+field it sets: a background session re-sends an upload whose response is slow, and
+WordPress creates an attachment for each copy.
+
### Android: permit cleartext to localhost
**Android hosts must add localhost to their network security configuration, or native
media handling will silently not run.**
-GutenbergKit serves media through a loopback HTTP server, which the editor reaches over
-cleartext `http://localhost`. Apps targeting API 28 or above deny cleartext by default, so
-without an entry the WebView blocks every upload request with
+On Android, GutenbergKit receives media uploads through a loopback HTTP server, which the
+editor reaches over cleartext `http://localhost`. Apps targeting API 28 or above deny
+cleartext by default, so without an entry the WebView blocks every upload request with
`ERR_CLEARTEXT_NOT_PERMITTED` before it leaves the page. `GutenbergView` detects this and
leaves the server down, so uploads fall back to the WebView's own path rather than failing
against a server they can never reach.
diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj
index 827ef5e65..5ad1d69fd 100644
--- a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj
+++ b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj
@@ -16,7 +16,6 @@
2468526B2EAACCA100ED1F09 /* AuthenticationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 246852682EAACCA100ED1F09 /* AuthenticationManager.swift */; };
2468526C2EAACCA100ED1F09 /* ConfigurationStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 246852692EAACCA100ED1F09 /* ConfigurationStorage.swift */; };
2FCF7A593017EC80008F5560 /* DemoAppLocale.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FCF7A503017EC80008F5560 /* DemoAppLocale.swift */; };
- BB0000012F11000000000001 /* GutenbergKitHTTP in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000012F11000000000002 /* GutenbergKitHTTP */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -53,7 +52,6 @@
files = (
246852562EAABB7800ED1F09 /* WordPressAPI in Frameworks */,
0CF6E04C2BEFF60E00EDEE8A /* GutenbergKit in Frameworks */,
- BB0000012F11000000000001 /* GutenbergKitHTTP in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -156,7 +154,6 @@
packageProductDependencies = (
0CF6E04B2BEFF60E00EDEE8A /* GutenbergKit */,
0C4F59A12BEFF4980028BD96 /* WordPressAPI */,
- BB0000012F11000000000002 /* GutenbergKitHTTP */,
);
productName = Gutenberg;
productReference = 0C4F598B2BEFF4970028BD96 /* Gutenberg.app */;
@@ -593,10 +590,6 @@
isa = XCSwiftPackageProductDependency;
productName = GutenbergKit;
};
- BB0000012F11000000000002 /* GutenbergKitHTTP */ = {
- isa = XCSwiftPackageProductDependency;
- productName = GutenbergKitHTTP;
- };
/* End XCSwiftPackageProductDependency section */
};
rootObject = 0C4F59832BEFF4970028BD96 /* Project object */;
diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme b/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme
index f11eb35e2..005ba8d75 100644
--- a/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme
+++ b/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme
@@ -30,16 +30,6 @@
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
-
-
-
-
.Continuation?
- @State private var externallyAccessible = true
- @State private var speedTestResults: [SpeedTestResult] = []
- @State private var isRunningSpeedTest = false
-
- struct LogEntry: Identifiable, Sendable {
- let id = UUID()
- let timestamp: Date
- let method: String
- let target: String
- let requestBodySize: Int
- }
-
- struct SpeedTestResult: Identifiable {
- let id = UUID()
- let size: Int
- let duration: TimeInterval
- var throughput: Double { Double(size) / duration }
- }
-
- private var isRunning: Bool { server != nil }
-
- var body: some View {
- List {
- Section {
- LabeledContent("Address") {
- if let server {
- Text(verbatim: "\(localAddress):\(server.port)")
- .monospaced()
- .textSelection(.enabled)
- } else {
- Text("Loading...")
- .foregroundStyle(.secondary)
- }
- }
-
- Toggle("Externally Accessible", isOn: $externallyAccessible)
- .disabled(!isRunning)
- .onChange(of: externallyAccessible) {
- Task {
- stopServer()
- await startServer()
- }
- }
-
- if isRunning {
- Button("Stop Server", role: .destructive) {
- stopServer()
- }
- } else if !isStarting {
- Button("Start Server") {
- Task { await startServer() }
- }
- }
- } footer: {
- if let errorMessage {
- Text(errorMessage).foregroundStyle(.red)
- }
- }
-
- Section("Speed Test") {
- if isRunning {
- Button(isRunningSpeedTest ? "Running..." : "Run Speed Test") {
- Task { await runSpeedTest() }
- }
- .disabled(isRunningSpeedTest)
- }
- if !speedTestResults.isEmpty {
- HStack {
- Text("Size")
- .frame(width: 80, alignment: .leading)
- Text("Time")
- .frame(width: 70, alignment: .trailing)
- Spacer()
- Text("Throughput")
- .foregroundStyle(.secondary)
- }
- .font(.system(.caption2, design: .monospaced))
- .foregroundStyle(.secondary)
- }
- ForEach(speedTestResults) { result in
- HStack {
- Text(ByteCountFormatter.string(fromByteCount: Int64(result.size), countStyle: .binary))
- .frame(width: 80, alignment: .leading)
- Text(String(format: "%.0f ms", result.duration * 1000))
- .frame(width: 70, alignment: .trailing)
- Spacer()
- Text(ByteCountFormatter.string(fromByteCount: Int64(result.throughput), countStyle: .binary) + "/s")
- .foregroundStyle(.secondary)
- }
- .font(.system(.caption, design: .monospaced))
- }
- }
-
- Section("Request Log") {
- if logs.isEmpty {
- ContentUnavailableView("No Requests", systemImage: "network")
- } else {
- ForEach(logs) { entry in
- VStack(alignment: .leading, spacing: 2) {
- Text("\(entry.method) \(entry.target)")
- .font(.system(.caption, design: .monospaced))
- HStack {
- Text(entry.timestamp, style: .time)
- Text(verbatim: "·")
- Text(verbatim: ByteCountFormatter.string(fromByteCount: Int64(entry.requestBodySize), countStyle: .binary))
- }
- .font(.caption2)
- .foregroundStyle(.secondary)
- }
- }
- }
- }
- }
- .navigationTitle("Media Proxy Server")
- .task {
- await startServer()
- }
- .onDisappear {
- stopServer()
- }
- }
-
- private func startServer() async {
- guard server == nil else { return }
- isStarting = true
- errorMessage = nil
-
- let (stream, continuation) = AsyncStream.makeStream(of: LogEntry.self)
- self.logContinuation = continuation
-
- do {
- // Authentication is disabled for this demo app — it is never shipped to
- // end users. Production code should always set requiresAuthentication: true.
- let s = try await HTTPServer.start(
- name: "media-proxy-demo",
- port: 8080,
- listenOnAllInterfaces: externallyAccessible,
- requiresAuthentication: false
- ) { request in
- let entry = LogEntry(
- timestamp: Date(),
- method: request.parsed.method,
- target: request.parsed.target,
- requestBodySize: request.parsed.body?.count ?? 0
- )
- continuation.yield(entry)
- return HTTPResponse(status: 200, body: Data("OK\n".utf8))
- }
- localAddress = externallyAccessible
- ? (Self.getLocalIPAddress() ?? "unknown")
- : "127.0.0.1"
- server = s
- isStarting = false
-
- // Note: logs grows without bound. This is acceptable for a demo app;
- // a production UI should cap the list or use a ring buffer.
- for await entry in stream {
- logs.insert(entry, at: 0)
- }
- } catch {
- errorMessage = error.localizedDescription
- isStarting = false
- }
- }
-
- private func runSpeedTest() async {
- guard let server else { return }
- isRunningSpeedTest = true
- speedTestResults = []
-
- let sizes = [128 * 1024, 512 * 1024, 1024 * 1024, 5 * 1024 * 1024, 10 * 1024 * 1024]
- // 127.0.0.1 is intentional — the speed test is a local self-benchmark,
- // not a device-to-device test. The server's externallyAccessible toggle
- // controls whether remote clients can connect.
- let url = URL(string: "http://127.0.0.1:\(server.port)/speed-test")!
-
- for size in sizes {
- let payload = Data(repeating: 0x42, count: size)
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.httpBody = payload
-
- let start = ContinuousClock.now
- _ = try? await URLSession.shared.data(for: request)
- let elapsed = start.duration(to: .now)
- let seconds = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18
-
- speedTestResults.append(SpeedTestResult(size: size, duration: seconds))
- }
-
- isRunningSpeedTest = false
- }
-
- private func stopServer() {
- logContinuation?.finish()
- logContinuation = nil
- server?.stop()
- server = nil
- }
-
- static func getLocalIPAddress() -> String? {
- var ifaddr: UnsafeMutablePointer?
- guard getifaddrs(&ifaddr) == 0, let firstAddr = ifaddr else { return nil }
- defer { freeifaddrs(ifaddr) }
-
- for ptr in sequence(first: firstAddr, next: { $0.pointee.ifa_next }) {
- let flags = Int32(ptr.pointee.ifa_flags)
- let addr = ptr.pointee.ifa_addr.pointee
-
- guard (flags & (IFF_UP | IFF_RUNNING)) == (IFF_UP | IFF_RUNNING) else { continue }
- guard addr.sa_family == UInt8(AF_INET) else { continue }
-
- let name = String(cString: ptr.pointee.ifa_name)
- guard name == "en0" || name == "en1" else { continue }
-
- var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
- if getnameinfo(
- ptr.pointee.ifa_addr,
- socklen_t(addr.sa_len),
- &hostname,
- socklen_t(hostname.count),
- nil, 0, NI_NUMERICHOST
- ) == 0 {
- return String(cString: hostname)
- }
- }
- return nil
- }
-}
diff --git a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift
index e27d66f8a..279bd7b41 100644
--- a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift
+++ b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift
@@ -7,8 +7,8 @@ public protocol EditorHTTPClientProtocol: Sendable {
/// Like ``perform(_:)`` but does **not** throw on a non-2xx status — returns
/// the raw response so the caller can relay WordPress's exact status and body.
- /// Used by the media upload server, which forwards WordPress's response (and
- /// its errors) to the editor unchanged.
+ /// Used for native media uploads, which forward WordPress's response (and its
+ /// errors) to the editor unchanged.
func performRaw(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse)
func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse)
@@ -89,26 +89,123 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol {
#endif
}()
+ /// The shortest inactivity timeout a media upload is allowed.
+ ///
+ /// `URLRequest.timeoutInterval` is an inactivity timer, and an upload goes silent
+ /// once its body is sent: WordPress creates the attachment and then generates image
+ /// sub-sizes before it answers. A slow host can take well over a minute, and a timer
+ /// that fires in that window reports a failure for an attachment that already exists
+ /// — an orphan the user then duplicates by retrying. Ten minutes outlasts the gateway
+ /// timeouts hosts put in front of PHP, so the host's own limit is the one that ends a
+ /// request that really is stuck.
+ public static let uploadInactivityTimeout: TimeInterval = 600
+
private let urlSession: URLSessionProtocol
private let authHeader: String
private let delegate: EditorHTTPClientDelegate?
private let requestTimeout: TimeInterval?
+ private let minimumTimeout: TimeInterval?
+
+ /// Requests in flight that an identical `perform(_:)` joins instead of sending again. Every
+ /// editor and service builds its own client, so this is shared across all of them.
+ static let inFlightRequests = InFlightTasks()
+
+ /// A request other callers can share: the request as it goes out, and the session it goes
+ /// out on. `URLRequest`'s own `==` ignores the timeout and the network service type, so
+ /// those are compared here; it ignores the body too, but a request with one isn't shared.
+ struct SharedRequest: Hashable, Sendable {
+ let request: URLRequest
+ let timeout: TimeInterval
+ let networkServiceType: URLRequest.NetworkServiceType
+ let session: ObjectIdentifier
+ }
public init(
urlSession: URLSessionProtocol,
authHeader: String,
delegate: EditorHTTPClientDelegate? = nil,
requestTimeout: TimeInterval? = nil
+ ) {
+ self.init(
+ urlSession: urlSession,
+ authHeader: authHeader,
+ delegate: delegate,
+ requestTimeout: requestTimeout,
+ minimumTimeout: nil
+ )
+ }
+
+ private init(
+ urlSession: URLSessionProtocol,
+ authHeader: String,
+ delegate: EditorHTTPClientDelegate?,
+ requestTimeout: TimeInterval?,
+ minimumTimeout: TimeInterval?
) {
self.urlSession = urlSession
self.authHeader = authHeader
self.delegate = delegate
self.requestTimeout = requestTimeout
+ self.minimumTimeout = minimumTimeout
}
+ /// Sends `urlRequest`, throwing for a non-2xx status.
+ ///
+ /// A request identical to one already in flight joins it rather than going out again, so
+ /// callers after the same site data — an editor and a prefetch, say — pay for one round
+ /// trip. Only a safe request without a body is shared, and only between clients no delegate
+ /// is watching. A request whose cache policy asks to skip the cache goes out alone: its
+ /// caller wants an answer no older than the call, and a request already in flight may
+ /// predate a write made since. Cancelling a caller ends its own wait; the request is
+ /// cancelled once no caller is left waiting on it.
public func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) {
-
let configuredRequest = self.configureRequest(urlRequest)
+ guard let sharedRequest = sharedRequest(forConfigured: configuredRequest) else {
+ return try await send(configuredRequest)
+ }
+ return try await Self.inFlightRequests.value(for: sharedRequest) { _ in
+ try await self.send(configuredRequest)
+ }
+ }
+
+ /// For tests: what `perform(_:)` shares `urlRequest` under, or `nil` if it goes out alone.
+ func sharedRequest(for urlRequest: URLRequest) -> SharedRequest? {
+ sharedRequest(forConfigured: configureRequest(urlRequest))
+ }
+
+ /// `nil` for a request that must go out alone: an unsafe method or a body, a cache policy
+ /// that asks for a fresh answer, a delegate that expects to see each request it asked for,
+ /// or a session that isn't an object — a shared request is keyed by the session's identity,
+ /// which only an object keeps.
+ private func sharedRequest(forConfigured request: URLRequest) -> SharedRequest? {
+ guard delegate == nil,
+ Self.sharableMethods.contains(request.httpMethod ?? "GET"),
+ request.httpBody == nil,
+ request.httpBodyStream == nil,
+ !Self.freshAnswerPolicies.contains(request.cachePolicy),
+ type(of: urlSession) is AnyClass
+ else {
+ return nil
+ }
+ return SharedRequest(
+ request: request,
+ timeout: request.timeoutInterval,
+ networkServiceType: request.networkServiceType,
+ session: ObjectIdentifier(urlSession as AnyObject)
+ )
+ }
+
+ private static let sharableMethods: Set = ["GET", "HEAD", "OPTIONS"]
+
+ /// The cache policies that ask the server afresh rather than trust a stored response, and
+ /// so won't take one already on its way.
+ private static let freshAnswerPolicies: Set = [
+ .reloadIgnoringLocalCacheData,
+ .reloadIgnoringLocalAndRemoteCacheData,
+ .reloadRevalidatingCacheData,
+ ]
+
+ private func send(_ configuredRequest: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await self.urlSession.data(for: configuredRequest)
self.delegate?.didPerformRequest(configuredRequest, response: response, data: .bytes(data))
@@ -162,20 +259,27 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol {
/// A sibling client tuned for large media uploads: it reuses this client's
/// session (preserving any custom configuration or pinning) and auth header,
- /// but drops the REST `requestTimeout`. That timeout is an inactivity timer
- /// (`URLRequest.timeoutInterval`); a short value set for snappy REST calls
- /// would also fire during the silent window while WordPress synchronously
- /// generates image sub-sizes inside `POST /wp/v2/media`, orphaning the
- /// attachment server-side and duplicating it on retry. Uploads instead use
- /// the request's default 60s inactivity timeout, mirroring Android's
- /// dedicated upload client (no total-duration cap).
+ /// drops the REST `requestTimeout`, and raises every request's inactivity
+ /// timeout to at least ``uploadInactivityTimeout``.
+ ///
+ /// Both halves guard the same window: the silence while WordPress generates image
+ /// sub-sizes inside `POST /wp/v2/media`. A short REST timeout would fire there, and
+ /// so does `URLRequest`'s own 60s default on a slow host — measured against a
+ /// WordPress whose response was held back 90s, the upload failed with
+ /// `NSURLErrorTimedOut` while the attachment it created stayed on the site. A
+ /// request that already asks for longer keeps its own value.
///
/// The request-observing `delegate` is carried over, so a host that installs
- /// one observes media uploads and passthroughs like every other request; only
- /// the REST `requestTimeout` is dropped. Sharing the observer across both
- /// clients is sound because `EditorHTTPClientDelegate` is `Sendable`.
+ /// one observes media uploads like every other request. Sharing the observer
+ /// across both clients is sound because `EditorHTTPClientDelegate` is `Sendable`.
public nonisolated func uploadClient() -> any EditorHTTPClientProtocol {
- EditorHTTPClient(urlSession: urlSession, authHeader: authHeader, delegate: delegate)
+ EditorHTTPClient(
+ urlSession: urlSession,
+ authHeader: authHeader,
+ delegate: delegate,
+ requestTimeout: nil,
+ minimumTimeout: Self.uploadInactivityTimeout
+ )
}
private func configureRequest(_ request: URLRequest) -> URLRequest {
@@ -186,6 +290,9 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol {
if let requestTimeout {
mutableRequest.timeoutInterval = requestTimeout
}
+ if let minimumTimeout, mutableRequest.timeoutInterval < minimumTimeout {
+ mutableRequest.timeoutInterval = minimumTimeout
+ }
// Prevent wordpress_logged_in cookies from being sent, which could interfere with
// application password authentication in the Authorization header.
diff --git a/ios/Sources/GutenbergKit/Sources/EditorLogging.swift b/ios/Sources/GutenbergKit/Sources/EditorLogging.swift
index 8ba4cc53b..75961f000 100644
--- a/ios/Sources/GutenbergKit/Sources/EditorLogging.swift
+++ b/ios/Sources/GutenbergKit/Sources/EditorLogging.swift
@@ -29,11 +29,14 @@ extension Logger {
/// Logs editor localization activity
static let localization = Logger(subsystem: "GutenbergKit", category: "localization")
- /// Logs upload server activity
- static let uploadServer = Logger(subsystem: "GutenbergKit", category: "upload-server")
+ /// Logs native media upload activity
+ static let mediaUpload = Logger(subsystem: "GutenbergKit", category: "media-upload")
/// Logs calls into the editor's JavaScript bridge
static let bridge = Logger(subsystem: "GutenbergKit", category: "bridge")
+
+ /// Logs the native REST relay's activity
+ static let restRelay = Logger(subsystem: "GutenbergKit", category: "rest-relay")
}
public struct SignpostMonitor: Sendable {
diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift
index 5026db0db..23a2613e8 100644
--- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift
+++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift
@@ -28,14 +28,14 @@ import UIKit
// │ WARMUP MODE │ │ DEPENDENCIES │ │ NO DEPENDENCIES │
// │ (isWarmupMode) │ │ PROVIDED │ │ (Async Flow) │
// │ │ │ (Fast Path) │ │ │
-// │ Load HTML without │ │ │ │ Spawn Task to fetch │
+// │ Load HTML without │ │ │ │ Start a loader to fetch │
// │ any dependencies │ │ loadEditor() │ │ dependencies │
// │ for prewarming │ │ immediately │ │ │
// └────────────────────┘ └────────────────────┘ └───────────────────────────────┘
// │ ▼
// │ ┌───────────────────────────────┐
-// │ │ prepareEditor() │
-// │ │ • Load editor dependencies │
+// │ │ EditorDependencyLoader │
+// │ │ • Fetch editor dependencies │
// │ └───────────────────────────────┘
// │ ▼
// │ ┌───────────────────────────────┐
@@ -66,12 +66,16 @@ import UIKit
//
// ## Flow 2: No Dependencies (Async Flow)
//
-// When no dependencies are provided, the controller fetches them asynchronously.
+// When no dependencies are provided, an `EditorDependencyLoader` fetches them
+// asynchronously and hands them to the fast path. The loader holds the controller
+// only weakly, so a controller released mid-fetch is freed at once, not when the
+// fetch ends.
+//
// This is a fallback behaviour – the host app should provide the dependencies if it can,
// because it'll be a much better user experience.
//
@MainActor
-public final class EditorViewController: UIViewController, GutenbergEditorControllerDelegate, UIAdaptivePresentationControllerDelegate, UIPopoverPresentationControllerDelegate, UISheetPresentationControllerDelegate {
+public final class EditorViewController: UIViewController, GutenbergEditorControllerDelegate, EditorDependencyLoaderDelegate, UIAdaptivePresentationControllerDelegate, UIPopoverPresentationControllerDelegate, UISheetPresentationControllerDelegate {
public let webView: WKWebView
public var configuration: EditorConfiguration
@@ -85,6 +89,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
/// The fetched or provided editor dependencies (settings, assets, preload data).
private var dependencies: EditorDependencies?
+ /// Fetches `dependencies` when none were provided at init.
+ private var dependencyLoader: EditorDependencyLoader?
+
/// Error encountered while loading dependencies.
private var error: Error? {
didSet {
@@ -176,21 +183,14 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
private let editorService: EditorService
private let httpClient: any EditorHTTPClientProtocol
private let mediaPicker: MediaPickerController?
+ let nativeFileInput = NativeFileInput()
private let controller: GutenbergEditorController
private let bundleProvider: EditorAssetBundleProvider
private let lockdownModeMonitor: LockdownModeMonitor
- /// Whether the host supplied anything for the native upload server to route.
- ///
- /// Read twice by `startUploadServer()` — once before starting, once after the bind
- /// returns — and the two reads have to agree. They did not: the first gained
- /// `mediaUploader` and the second was left checking the processor alone, so an
- /// uploader-only host bound a listener, immediately stopped it, and fell back to the
- /// WebView path with nothing logged. One property, so they cannot disagree again.
- private var hasMediaHandling: Bool {
- mediaProcessor != nil || mediaUploader != nil
- }
-
- private(set) var uploadServer: MediaUploadServer?
+ /// Receives the page's media uploads over `gbk-upload:` and delivers them. Enabled
+ /// when the host supplied a processor or uploader and the site credentials to
+ /// upload with; ``stopMediaHandling()`` disables it.
+ let mediaUploadSchemeHandler: MediaUploadSchemeHandler
// MARK: - Private Properties (UI)
@@ -314,6 +314,24 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
// Register media file scheme handler for serving local media via gbk-media-file:// URLs
config.setURLSchemeHandler(MediaFileSchemeHandler(), forURLScheme: MediaFileSchemeHandler.scheme)
+ // The page sends the site's REST API requests here for native code to relay.
+ config.setURLSchemeHandler(
+ RestRelaySchemeHandler(relay: RestRelay(configuration: configuration)),
+ forURLScheme: RestRelaySchemeHandler.scheme
+ )
+
+ // Scheme handlers can only be registered before the web view exists, so the
+ // upload handler is built here, from what the host handed over, even though the
+ // page won't use it until it loads.
+ let uploadHandler = MediaUploadSchemeHandler(service: Self.makeMediaUploadService(
+ configuration: configuration,
+ httpClient: httpClient,
+ processor: mediaProcessor,
+ uploader: mediaUploader
+ ))
+ self.mediaUploadSchemeHandler = uploadHandler
+ config.setURLSchemeHandler(uploadHandler, forURLScheme: MediaUploadSchemeHandler.scheme)
+
config.applicationNameForUserAgent = "GutenbergKit/\(GutenbergKitVersion.version)"
self.webView = GBWebView(frame: .zero, configuration: config)
@@ -367,23 +385,11 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
if let dependencies {
// FAST PATH: Dependencies were provided at init() - load immediately.
- // Not cancellable: cancelling mid-`startUploadServer()` silently disables
- // native uploads for the session (#357).
- Task(priority: .userInitiated) { [weak self] in
- do {
- try await self?.loadEditor(dependencies: dependencies)
- } catch {
- self?.failToLoad(error)
- }
- }
+ startLoadingEditor(dependencies: dependencies)
} else {
- // ASYNC FLOW: No dependencies - fetch them, then load as above.
- // Not cancellable either, for the same reason plus one: nothing restarts
- // the fetch, so the editor never recovers from a cancel. Note that
- // `viewDidDisappear` fires when the editor is merely covered. See #651.
- Task(priority: .userInitiated) { [weak self] in
- await self?.prepareEditor()
- }
+ // ASYNC FLOW: No dependencies - fetch them, then take the fast path.
+ displayProgressView()
+ dependencyLoader = EditorDependencyLoader(service: editorService, delegate: self)
}
}
@@ -397,18 +403,17 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
removeNavigationOverlay()
}
- /// Releases the editor's media handling: stops the local upload server, drops the
- /// host's ``mediaProcessor`` and ``mediaUploader``, and withdraws the upload
- /// endpoint from the page.
+ /// Releases the editor's media handling: stops accepting native uploads, cancels
+ /// the ones in flight, and drops the host's ``mediaProcessor`` and ``mediaUploader``.
///
- /// Most hosts never need this. Releasing the editor runs `deinit`, which does the
- /// same work. It is only required when a handler holds the editor back — which
- /// happens if you conformed the object that owns it, the one shape ``mediaProcessor``
- /// asks you to avoid — because that cycle keeps `deinit` from ever
- /// running, stranding a bound loopback `NWListener` for every editor opened.
+ /// Most hosts never need this. Releasing the editor releases the handlers with it.
+ /// It is only required when a handler holds the editor back — which happens if you
+ /// conformed the object that owns it, the one shape ``mediaProcessor`` asks you to
+ /// avoid — because that cycle keeps the editor, and the handlers, alive forever.
///
- /// Terminal, not a pause: this editor cannot upload or delete media afterwards, and
- /// any upload in flight is cancelled — though cancellation is cooperative, so a
+ /// Terminal, not a pause: this editor cannot upload or delete media natively
+ /// afterwards. Uploads the page starts later go straight to WordPress instead. Any
+ /// upload in flight is cancelled — though cancellation is cooperative, so a
/// `processFile` that ignores it runs to completion and holds the processor until it
/// returns. Call it when the editor is going away — not
/// when it is covered, backgrounded, or otherwise coming back. Calling it more than
@@ -433,98 +438,35 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
// What is *not* observable is whether a detachment is permanent. A host may
// re-present or re-attach the same editor instance later, and at the moment of
// the callback that is indistinguishable from the last one. Because this call is
- // terminal — the listener cannot restart and the page is told to stop using it —
- // guessing wrong permanently disables media in an editor that survived, which is
+ // terminal — the handlers are released and cannot be recovered — guessing wrong
+ // permanently disables native media in an editor that survived, which is
// strictly worse than the leak it would have prevented.
//
- // So this stays the host's call while the action is terminal. Make the endpoint
- // recoverable (have the page request the port over the bridge instead of baking
- // it in at document start) and the trade reverses.
- uploadServer?.stop()
- uploadServer = nil
+ // So this stays the host's call while the action is terminal.
+ mediaUploadSchemeHandler.disable()
mediaProcessor = nil
mediaUploader = nil
- revokeNativeUploadEndpoint()
}
- /// Withdraws the loopback endpoint from the page so media requests fall back to the
- /// WebView's default path instead of failing against a port nothing is listening on.
- ///
- /// `nativeMediaUploadMiddleware` re-reads `nativeUploadPort`/`nativeUploadToken` on
- /// every request and skips the native path when no port is advertised — but it
- /// deliberately does *not* retry a failed native upload directly, on the stated
- /// assumption that an advertised port is a reachable one ("cleared on stop"). Until
- /// this existed nothing cleared it, so stopping the server left every image insert
- /// failing with a connection error on a working connection.
- ///
- /// Two copies hold the endpoint and both have to go: the live page, and the injected
- /// user script, which would otherwise restore the dead port verbatim at the next
- /// document start — including the reload that recovers a terminated WebContent
- /// process.
- private func revokeNativeUploadEndpoint() {
- webView.evaluateJavaScript(
- """
- if (window.GBKit) {
- window.GBKit.nativeUploadPort = null;
- window.GBKit.nativeUploadToken = null;
- }
- """
- ) { _, error in
- // Logged rather than surfaced: this runs while the editor is going away, so
- // there is no one to tell. Silence would be worse than noise — a failure here
- // leaves the live page pointed at a port nothing is listening on, which is the
- // exact failure this method exists to prevent.
- if let error {
- Logger.uploadServer.error("Failed to withdraw the native upload endpoint from the page: \(error)")
- }
- }
-
- // Rebuilt with `uploadServer` already nil, so the replacement advertises no
- // endpoint. The load path is the only other `addUserScript` call site, so removing
- // all of them drops exactly the script being replaced.
- webView.configuration.userContentController.removeAllUserScripts()
- guard let dependencies else { return }
- do {
- webView.configuration.userContentController.addUserScript(
- try buildEditorConfiguration(dependencies: dependencies)
- )
- } catch {
- // The load path lets this throw and aborts; here the page is already up, so
- // the cost is narrower and lands later: the next document start gets no
- // `window.GBKit` at all rather than one with a stale port.
- Logger.uploadServer.error("Failed to rebuild the editor configuration after stopping media handling: \(error)")
- }
- }
+ // MARK: - Async Flow (EditorDependencyLoaderDelegate)
- deinit {
- // The ordinary path: with no cycle, ARC releases the handlers when the editor
- // goes and this stops the server. A host that retains the editor from its own
- // handler never reaches here — `stopMediaHandling()` is its way out.
- uploadServer?.stop()
+ func dependencyLoader(_ loader: EditorDependencyLoader, didUpdate progress: EditorProgress) {
+ progressView.setProgress(progress, animated: true)
}
- /// Fetches all required dependencies and then loads the editor.
- ///
- /// This method is the entry point for the **Async Flow** (when no dependencies were provided at init).
- @MainActor
- private func prepareEditor() async {
- self.displayProgressView()
- defer { self.hideProgressView() }
+ func dependencyLoader(_ loader: EditorDependencyLoader, didLoad dependencies: EditorDependencies) {
+ hideProgressView()
- do {
- // EditorService.prepare() fetches dependencies concurrently with progress reporting
- let dependencies = try await self.editorService.prepare { @MainActor [weak self] progress in
- self?.progressView.setProgress(progress, animated: true)
- }
+ // Store dependencies for later use (e.g., HTMLPreviewManager)
+ self.dependencies = dependencies
- // Store dependencies for later use (e.g., HTMLPreviewManager)
- self.dependencies = dependencies
+ // Continue to the shared loading path
+ startLoadingEditor(dependencies: dependencies)
+ }
- // Continue to the shared loading path
- try await self.loadEditor(dependencies: dependencies)
- } catch {
- self.failToLoad(error)
- }
+ func dependencyLoader(_ loader: EditorDependencyLoader, didFailWith error: any Error) {
+ hideProgressView()
+ failToLoad(error)
}
private func failToLoad(_ error: Error) {
@@ -534,6 +476,17 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
// MARK: - Shared Loading Path: Load Editor into WebView
+ /// Runs `loadEditor(dependencies:)` — the step both flows end on.
+ private func startLoadingEditor(dependencies: EditorDependencies) {
+ Task(priority: .userInitiated) { [weak self] in
+ do {
+ try await self?.loadEditor(dependencies: dependencies)
+ } catch {
+ self?.failToLoad(error)
+ }
+ }
+ }
+
/// Loads the editor HTML into the WebView with the given dependencies.
///
/// This is the **shared loading path** used by both flows after dependencies are available.
@@ -548,9 +501,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
// Set asset bundle for the URL scheme handler to serve cached plugin/theme assets
self.bundleProvider.set(bundle: dependencies.assetBundle)
- // Start the local upload server for native media processing
- await startUploadServer()
-
// Build and inject editor configuration as window.GBKit
let editorConfig = try buildEditorConfiguration(dependencies: dependencies)
webView.configuration.userContentController.addUserScript(editorConfig)
@@ -586,8 +536,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
let gbkitGlobal = try GBKitGlobal(
configuration: self.configuration,
dependencies: dependencies,
- nativeUploadPort: uploadServer.map { Int($0.port) },
- nativeUploadToken: uploadServer?.token
+ nativeUploadScheme: mediaUploadSchemeHandler.isEnabled ? MediaUploadSchemeHandler.scheme : nil,
+ restRelayBaseURL: RestRelaySchemeHandler.baseURL
)
return WKUserScript(
source: Self.configurationScript(gbkitGlobal: try gbkitGlobal.toString()),
@@ -612,72 +562,42 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
"""
}
- /// Starts the local HTTP server for routing file uploads through native processing.
+ /// The service behind the upload scheme handler, or `nil` when the editor has no
+ /// native media handling.
///
- /// The server binds to localhost on a random port. If it fails to start, the editor
- /// falls back to Gutenberg's default upload behavior (the JS override won't activate
- /// because `nativeUploadPort` will be nil in GBKit).
- func startUploadServer() async {
- // Nothing to route through the native server unless the host provided a
- // processor or an uploader. The editor owns whichever it was given — both
- // properties are strong — so there's no released-before-load case to guard
- // against; they live as long as it does.
- guard hasMediaHandling else {
- return
- }
-
- // The native upload server relays through InternalMediaClient, which needs a
- // site root and an auth header (every host provides one — the editor injects
- // it because the WebView has no auth cookies). Without both there is nothing
- // to upload through, so leave the server down and let uploads fall to the
- // default WebView path rather than start a server that could only fail.
- //
- // Only a `mediaProcessor` can reach this return: a `mediaUploader` without
- // usable credentials already trapped in `init`, so by here it has credentials.
- //
- // `MediaServerCredentials` owns both the predicate and that trap so they are
- // reachable from the host test suite — this file is not.
+ /// Native uploads need something to route — a processor or an uploader — and the
+ /// site credentials to deliver with: `InternalMediaClient` needs a site root and an
+ /// auth header (every host provides one; the web view has no auth cookies). Without
+ /// both, uploads go straight from the page to WordPress. Only a processor can be
+ /// missing credentials here: a `mediaUploader` without them already trapped in
+ /// `init`.
+ private static func makeMediaUploadService(
+ configuration: EditorConfiguration,
+ httpClient: any EditorHTTPClientProtocol,
+ processor: (any MediaProcessor)?,
+ uploader: (any MediaUploader)?
+ ) -> MediaUploadService? {
+ guard processor != nil || uploader != nil else { return nil }
guard MediaServerCredentials.areUsable(
siteApiRoot: configuration.siteApiRoot,
authHeader: configuration.authHeader
) else {
- return
+ return nil
}
-
- let internalClient = InternalMediaClient(
- httpClient: httpClient.uploadClient(),
- siteApiRoot: configuration.siteApiRoot,
- siteApiNamespace: configuration.siteApiNamespace
- )
-
- do {
- let server = try await MediaUploadServer.start(
- processor: mediaProcessor,
- uploader: mediaUploader,
- internalClient: internalClient
+ return MediaUploadService(
+ processor: processor,
+ uploader: uploader,
+ internalClient: InternalMediaClient(
+ httpClient: httpClient.uploadClient(),
+ siteApiRoot: configuration.siteApiRoot,
+ siteApiNamespace: configuration.siteApiNamespace
)
-
- // `stopMediaHandling()` can land while the bind is in flight: it is a
- // main-actor call and this is suspended. It clears both handlers, so the
- // entry guard's condition failing here means media handling was stopped after
- // this started, and storing the server would undo a terminal call — the page
- // would be handed a port that was just withdrawn, and in the cycle the call
- // exists for, `deinit` never runs to stop it.
- guard hasMediaHandling else {
- server.stop()
- return
- }
- self.uploadServer = server
- } catch {
- Logger.uploadServer.error("Failed to start upload server: \(error). Falling back to default upload behavior.")
- }
+ )
}
/// Deletes all cached editor data for all sites
public static func deleteAllData() throws {
- if FileManager.default.directoryExists(at: Paths.defaultCacheRoot) {
- try FileManager.default.removeItem(at: Paths.defaultCacheRoot)
- }
+ try EditorURLCache.deleteAll()
if FileManager.default.directoryExists(at: Paths.defaultStorageRoot) {
try FileManager.default.removeItem(at: Paths.defaultStorageRoot)
@@ -899,18 +819,52 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
return
}
do {
- let object = try makeJavaScriptCompatibleDictionary(with: selection)
- _ = try await webView.callAsyncJavaScript(
- "window.blockInserter?.insertMedia(selection)",
- arguments: ["selection": object],
- in: nil,
- contentWorld: .page
- )
+ var items: [Any] = []
+ var files: [URL] = []
+ for media in selection {
+ let (item, file) = try javaScriptMediaItem(for: media)
+ items.append(item)
+ if let file {
+ files.append(file)
+ }
+ }
+ try await nativeFileInput.offer(files, to: webView) {
+ _ = try await webView.callAsyncJavaScript(
+ "return await window.blockInserter?.insertMedia(selection)",
+ arguments: ["selection": items],
+ in: nil,
+ contentWorld: .page
+ )
+ }
} catch {
assertionFailure("Failed to serialize or insert media: \(error)")
}
}
+ /// The media item as the page's `insertMedia` takes it, and the file to offer the
+ /// page for it.
+ ///
+ /// A file the editor imported is offered through ``NativeFileInput``, and its item
+ /// is marked `nativeFile` so the page asks for it. Anything else — a media library
+ /// item, a remote URL, any file on an OS that can't offer one — goes as it is, and
+ /// the page fetches it.
+ func javaScriptMediaItem(for media: MediaInfo) throws -> (item: Any, file: URL?) {
+ let item = try makeJavaScriptCompatibleDictionary(with: media)
+ guard NativeFileInput.isSupported,
+ media.id == nil,
+ var dictionary = item as? [String: Any],
+ let url = media.url.flatMap(URL.init(string:)),
+ url.scheme == MediaFileSchemeHandler.scheme,
+ let fileURL = MediaFileManager.fileURL(for: url),
+ FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else {
+ return (item, nil)
+ }
+
+ dictionary["type"] = media.type ?? MediaFileManager.mimeType(forExtension: fileURL.pathExtension)
+ dictionary["nativeFile"] = true
+ return (dictionary, fileURL)
+ }
+
private func insertPatternFromInserter(_ patternName: String) {
let escapedName = patternName.replacingOccurrences(of: "'", with: "\\'")
evaluate("window.blockInserter?.insertPattern('\(escapedName)')")
diff --git a/ios/Sources/GutenbergKit/Sources/Helpers/InFlightTasks.swift b/ios/Sources/GutenbergKit/Sources/Helpers/InFlightTasks.swift
new file mode 100644
index 000000000..4d4e13697
--- /dev/null
+++ b/ios/Sources/GutenbergKit/Sources/Helpers/InFlightTasks.swift
@@ -0,0 +1,166 @@
+import Foundation
+
+/// Work in flight, one task per key, each shared by every caller asking for that key.
+///
+/// A second caller for a key joins the task already running for it rather than starting the
+/// same work again. The task belongs to no caller: it runs on its own, so one caller's
+/// cancellation can't end it for the others. Cancelling a caller ends that caller's wait at once,
+/// and the task is cancelled only when no caller is left waiting on it. A caller that has left
+/// hears no further progress, though a progress call already under way when it leaves runs on.
+/// A caller that joins at a higher priority than the task's raises the task to match.
+final class InFlightTasks: @unchecked Sendable {
+
+ /// Guards `joinable`, and every ``Entry`` and ``Waiter``.
+ private let lock = NSLock()
+
+ /// The tasks a new caller joins. A task leaves when it finishes, or when its last waiter leaves.
+ private var joinable: [Key: Entry] = [:]
+
+ /// Returns the value for `key` from the task in flight for it, or from a new one that `run`
+ /// performs. `progress` hears the task's progress from when this caller joins until it leaves.
+ func value(
+ for key: Key,
+ progress: EditorProgressCallback? = nil,
+ run: @escaping @Sendable (_ report: @escaping EditorProgressCallback) async throws -> Value
+ ) async throws -> Value {
+ let waiter = Waiter(progress: progress)
+ return try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation { continuation in
+ join(key, waiter, continuation, run)
+ }
+ } onCancel: {
+ leave(waiter)
+ }
+ }
+
+ /// For tests: how many callers are waiting on the task in flight for `key`.
+ func waiterCount(for key: Key) -> Int {
+ lock.withLock { joinable[key]?.waiters.count ?? 0 }
+ }
+
+ /// For tests: the task in flight for `key`. It outlives the wait of a caller that leaves,
+ /// so a test of what an abandoned task does has to wait for the task itself.
+ func task(for key: Key) -> Task? {
+ lock.withLock { joinable[key]?.task }
+ }
+
+ private func join(
+ _ key: Key,
+ _ waiter: Waiter,
+ _ continuation: CheckedContinuation,
+ _ run: @escaping @Sendable (_ report: @escaping EditorProgressCallback) async throws -> Value
+ ) {
+ let (isCancelled, joined) = lock.withLock { () -> (Bool, Task?) in
+ // Cancelled before getting here, its `onCancel` has run and found nothing to leave.
+ guard !Task.isCancelled else { return (true, nil) }
+ let running = joinable[key]
+ let entry = running ?? start(key, run)
+ waiter.continuation = continuation
+ waiter.entry = entry
+ entry.waiters.append(waiter)
+ return (false, running?.task)
+ }
+ if isCancelled {
+ continuation.resume(throwing: CancellationError())
+ } else if let joined {
+ raise(joined)
+ }
+ }
+
+ /// Raises `task` to the calling task's priority. A task runs at the priority of the caller
+ /// that started it, and a caller waiting on a continuation doesn't escalate it the way one
+ /// awaiting `task.value` would — so an editor joining a background prefetch would otherwise
+ /// wait at the prefetch's priority. Escalation needs iOS 26 or macOS 26; before that, the
+ /// task keeps the priority it started with.
+ private func raise(_ task: Task) {
+ if #available(iOS 26, macOS 26, *) {
+ task.escalatePriority(to: Task.currentPriority)
+ }
+ }
+
+ /// Starts the task for `key`. Called with `lock` held.
+ private func start(
+ _ key: Key,
+ _ run: @escaping @Sendable (_ report: @escaping EditorProgressCallback) async throws -> Value
+ ) -> Entry {
+ let entry = Entry(key: key)
+ joinable[key] = entry
+ entry.task = Task {
+ let result: Result
+ do {
+ result = .success(try await run { progress in await self.report(progress, from: entry) })
+ } catch {
+ result = .failure(error)
+ }
+ self.finish(entry, with: result)
+ }
+ return entry
+ }
+
+ /// Tells every waiter still waiting, one at a time. Each is checked again just before its
+ /// turn: an earlier waiter's callback can suspend for as long as it likes, and a later waiter
+ /// can leave meanwhile — after which its own caller has moved on.
+ private func report(_ progress: EditorProgress, from entry: Entry) async {
+ let waiters = lock.withLock { entry.waiters }
+ for waiter in waiters {
+ let callback = lock.withLock { entry.waiters.contains { $0 === waiter } ? waiter.progress : nil }
+ await callback?(progress)
+ }
+ }
+
+ private func finish(_ entry: Entry, with result: Result) {
+ let continuations = lock.withLock {
+ if joinable[entry.key] === entry {
+ joinable[entry.key] = nil
+ }
+ entry.task = nil
+ defer { entry.waiters = [] }
+ return entry.waiters.compactMap(\.continuation)
+ }
+ for continuation in continuations {
+ continuation.resume(with: result)
+ }
+ }
+
+ private func leave(_ waiter: Waiter) {
+ let (continuation, abandoned) = lock.withLock { () -> (CheckedContinuation?, Task?) in
+ guard let entry = waiter.entry, let index = entry.waiters.firstIndex(where: { $0 === waiter }) else {
+ return (nil, nil)
+ }
+ entry.waiters.remove(at: index)
+ guard entry.waiters.isEmpty else {
+ return (waiter.continuation, nil)
+ }
+ // No one is left waiting: stop the task, and let the next caller start afresh rather
+ // than join one on its way out.
+ if joinable[entry.key] === entry {
+ joinable[entry.key] = nil
+ }
+ return (waiter.continuation, entry.task)
+ }
+ continuation?.resume(throwing: CancellationError())
+ abandoned?.cancel()
+ }
+
+ /// One task and the callers waiting on it. Guarded by `lock`.
+ private final class Entry: @unchecked Sendable {
+ let key: Key
+ var task: Task?
+ var waiters: [Waiter] = []
+
+ init(key: Key) {
+ self.key = key
+ }
+ }
+
+ /// One ``value(for:progress:run:)`` call. Guarded by `lock`.
+ private final class Waiter: @unchecked Sendable {
+ let progress: EditorProgressCallback?
+ var continuation: CheckedContinuation?
+ var entry: Entry?
+
+ init(progress: EditorProgressCallback?) {
+ self.progress = progress
+ }
+ }
+}
diff --git a/ios/Sources/GutenbergKit/Sources/Media/BackgroundActivity.swift b/ios/Sources/GutenbergKit/Sources/Media/BackgroundActivity.swift
new file mode 100644
index 000000000..ae01e9ee1
--- /dev/null
+++ b/ios/Sources/GutenbergKit/Sources/Media/BackgroundActivity.swift
@@ -0,0 +1,54 @@
+import Foundation
+
+/// Runs `operation` while holding an assertion that asks iOS to keep the app running
+/// if it moves to the background.
+///
+/// iOS suspends an app about a second after it leaves the foreground. An upload that is
+/// still sending its body, or waiting for WordPress to answer, then stalls until the app
+/// comes back — by which time the request has usually failed. The assertion buys about
+/// thirty seconds (measured on iOS 27, with Low Power Mode on or off), which covers the
+/// common case of a photo on a working connection. It does not cover a large video on a
+/// slow one; nothing that keeps the upload in this process can.
+///
+/// `ProcessInfo.performExpiringActivity` rather than `UIApplication.beginBackgroundTask`:
+/// it needs neither the main thread nor `UIApplication`, so it can be taken from any
+/// executor, and it behaves the same in the simulator.
+func withBackgroundActivity(
+ _ reason: String,
+ isolation: isolated (any Actor)? = #isolation,
+ _ operation: () async throws -> T
+) async rethrows -> T {
+ #if os(iOS)
+ let activity = ExpiringActivity(reason: reason)
+ defer { activity.end() }
+ #endif
+ return try await operation()
+}
+
+#if os(iOS)
+/// An expiring-activity assertion held until ``end()``.
+///
+/// `performExpiringActivity` keeps the assertion only while its block runs, so the
+/// block parks on a semaphore until `end()` releases it. When iOS expires the assertion
+/// it calls the block a second time with `expired == true` — on another thread, while
+/// the first call is still parked — and that call releases the first one so the
+/// assertion is returned promptly.
+private struct ExpiringActivity {
+ private let released = DispatchSemaphore(value: 0)
+
+ init(reason: String) {
+ let released = released
+ ProcessInfo.processInfo.performExpiringActivity(withReason: reason) { expired in
+ if expired {
+ released.signal()
+ } else {
+ released.wait()
+ }
+ }
+ }
+
+ func end() {
+ released.signal()
+ }
+}
+#endif
diff --git a/ios/Sources/GutenbergKit/Sources/Media/InternalMediaClient.swift b/ios/Sources/GutenbergKit/Sources/Media/InternalMediaClient.swift
new file mode 100644
index 000000000..3e56538d3
--- /dev/null
+++ b/ios/Sources/GutenbergKit/Sources/Media/InternalMediaClient.swift
@@ -0,0 +1,287 @@
+import Foundation
+import OSLog
+
+// MARK: - Errors
+
+/// Errors from the native media upload pipeline.
+enum UploadError: Error, LocalizedError {
+ case noUploader
+ case streamReadFailed
+
+ var errorDescription: String? {
+ switch self {
+ case .noUploader: "No media uploader or internal media client configured"
+ case .streamReadFailed: "Failed to read upload stream"
+ }
+ }
+}
+
+// MARK: - Internal Media Client
+
+/// GutenbergKit's own client for the configured site, built from the site credentials
+/// in `EditorConfiguration`.
+///
+/// Not an implementation of any host-facing protocol — it is the thing that actually
+/// performs GutenbergKit's media requests. It delivers uploads the host did not take
+/// over, and relays the editor's media deletes: every attachment lives on the
+/// configured site, so that is where its deletion goes.
+class InternalMediaClient: @unchecked Sendable {
+ private let httpClient: EditorHTTPClientProtocol
+ private let siteApiRoot: URL
+ private let siteApiNamespace: String?
+
+ init(httpClient: EditorHTTPClientProtocol, siteApiRoot: URL, siteApiNamespace: [String] = []) {
+ self.httpClient = httpClient
+ self.siteApiRoot = siteApiRoot
+ self.siteApiNamespace = siteApiNamespace.first
+ }
+
+ /// The WordPress media endpoint URL, built through the shared
+ /// ``WordPressRESTURL`` namespacing (so it matches every other REST URL) and
+ /// carrying the original request query (e.g. `?_embed`) through to WordPress.
+ private func mediaEndpointURL(query: String, attachmentId: String? = nil) -> URL {
+ let path = attachmentId.map { "/wp/v2/media/\($0)" } ?? "/wp/v2/media"
+ let base = WordPressRESTURL.namespaced(apiRoot: siteApiRoot, path: path, namespace: siteApiNamespace)
+ guard !query.isEmpty else { return base }
+ // `query` is the raw request query in wire form (leading "?"). Set it via
+ // `percentEncodedQuery` so a value that isn't URL-safe can't make
+ // `URL(string:)` return nil and silently drop the query.
+ var components = URLComponents(url: base, resolvingAgainstBaseURL: false)
+ components?.percentEncodedQuery = String(query.dropFirst())
+ return components?.url ?? base
+ }
+
+ /// Uploads a file to `POST /wp/v2/media` as `multipart/form-data`, streaming it
+ /// from disk, and relays WordPress's response verbatim — non-2xx included.
+ ///
+ /// - Parameters:
+ /// - fields: The editor's other form fields (e.g. `post`), sent ahead of the file
+ /// in the order given.
+ /// - query: The editor's request query in wire form (leading `?`), or empty.
+ func upload(fileURL: URL, mimeType: String, filename: String, fields: [MediaUploadField], query: String) async throws -> MediaUploadResponse {
+ let boundary = UUID().uuidString
+ let extraFields = fields.map { (name: $0.name, value: Data($0.value.utf8)) }
+
+ let (bodyStream, contentLength) = try Self.multipartBodyStream(
+ fileURL: fileURL, boundary: boundary, filename: filename, mimeType: mimeType, extraFields: extraFields
+ )
+
+ var request = URLRequest(url: mediaEndpointURL(query: query))
+ request.httpMethod = "POST"
+ request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ request.setValue("\(contentLength)", forHTTPHeaderField: "Content-Length")
+ request.httpBodyStream = bodyStream
+
+ return try await performUpload(request)
+ }
+
+ /// Deletes an attachment, relaying WordPress's response verbatim.
+ ///
+ /// Carries the editor's query through unchanged — core's cleanup sends
+ /// `?force=true`, without which WordPress trashes rather than deletes.
+ func deleteMedia(attachmentId: String, query: String) async throws -> MediaUploadResponse {
+ var request = URLRequest(url: mediaEndpointURL(query: query, attachmentId: attachmentId))
+ request.httpMethod = "DELETE"
+
+ // Authorization is applied centrally by the HTTP client, as for uploads.
+ let (data, response) = try await httpClient.performRaw(request)
+ return MediaUploadResponse(
+ statusCode: response.statusCode,
+ body: data,
+ headers: Self.relayableHeaders(from: response)
+ )
+ }
+
+ /// Sends the assembled upload request to WordPress and relays the response.
+ ///
+ /// The request body is a **one-shot** stream (a bound-pair pipe for the
+ /// multipart body), so it can't be replayed. That
+ /// only matters if URLSession has to resend the body — i.e. a `307`/`308`
+ /// redirect that preserves the `POST`. `301`/`302`/`303` downgrade to a
+ /// bodyless GET, and a Bearer-token `401` doesn't trigger a resend, so those
+ /// never replay the stream. WordPress core never redirects `POST /wp/v2/media`;
+ /// if a proxy or misconfiguration did, the resend would read the now-exhausted
+ /// stream and send an empty body, which WordPress rejects — a clean failure,
+ /// not a truncated attachment (the stream is consumed, never rewound). We
+ /// intentionally don't implement `needNewBodyStream`, or buffer the body to a
+ /// replayable file, for that rare case.
+ private func performUpload(_ request: URLRequest) async throws -> MediaUploadResponse {
+ // The body is fed by a background writer thread via a bound stream pair
+ // (`multipartBodyStream`). If
+ // the request is cancelled or fails, URLSession may abandon the stream
+ // without draining it, leaving that writer blocked forever on a full buffer
+ // — leaking the thread and its open file handle. Closing the input stream on
+ // every exit breaks the pair so the writer's write() fails and it unwinds.
+ defer { request.httpBodyStream?.close() }
+
+ // Relay WordPress's response verbatim — including non-2xx statuses — so
+ // the editor sees WordPress's real status and error body, exactly as a
+ // direct upload would. `performRaw` does not throw on non-2xx.
+ let (data, response) = try await httpClient.performRaw(request)
+ return MediaUploadResponse(
+ statusCode: response.statusCode,
+ body: data,
+ headers: Self.relayableHeaders(from: response)
+ )
+ }
+
+ /// The upstream headers worth relaying to the editor.
+ ///
+ /// Deliberately an allowlist rather than a filtered passthrough: the body is
+ /// re-sent with a recomputed length, so relaying upstream entity or
+ /// transport headers wholesale would risk contradicting what the server
+ /// actually sends.
+ private static let relayableHeaderNames = ["x-wp-upload-attachment-id"]
+
+ /// Picks the headers to relay out of an upstream response.
+ private static func relayableHeaders(from response: HTTPURLResponse) -> [String: String] {
+ var headers: [String: String] = [:]
+ for name in relayableHeaderNames {
+ if let value = response.value(forHTTPHeaderField: name) {
+ headers[name] = value
+ }
+ }
+ return headers
+ }
+
+ // MARK: - Streaming Multipart Body
+
+ /// Builds a multipart/form-data body as an `InputStream` that streams the
+ /// file from disk without loading it into memory.
+ ///
+ /// Uses a bound stream pair with a background writer thread.
+ ///
+ /// - Returns: A tuple of the input stream and the total content length.
+ static func multipartBodyStream(
+ fileURL: URL,
+ boundary: String,
+ filename: String,
+ mimeType: String,
+ extraFields: [(name: String, value: Data)]
+ ) throws -> (InputStream, Int) {
+ // The non-file parts (post, additionalData) go into the preamble ahead of the streamed
+ // file; they're small, and `contentLength` counts them via `preamble.count`. Their
+ // values are appended as raw bytes rather than through `String(data:encoding:)`, which
+ // returns nil on bad UTF-8 — and the `?? ""` you'd reach for behind it would quietly
+ // drop a whole field.
+ var preamble = Data()
+ for field in extraFields {
+ preamble.append(Data("--\(boundary)\r\n".utf8))
+ preamble.append(Data("Content-Disposition: form-data; name=\"\(escapeQuotedParameter(field.name))\"\r\n\r\n".utf8))
+ preamble.append(field.value)
+ preamble.append(Data("\r\n".utf8))
+ }
+ preamble.append(Data("--\(boundary)\r\n".utf8))
+ preamble.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(escapeQuotedParameter(filename))\"\r\n".utf8))
+ // `mimeType` is a client-supplied Content-Type value; strip CR/LF so a
+ // crafted value can't inject additional headers. (Quotes are legal in
+ // Content-Type parameters, so they're left intact.)
+ let safeMimeType = mimeType.replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: "")
+ preamble.append(Data("Content-Type: \(safeMimeType)\r\n\r\n".utf8))
+ let epilogue = Data("\r\n--\(boundary)--\r\n".utf8)
+
+ guard let fileSize = try FileManager.default.attributesOfItem(atPath: fileURL.path(percentEncoded: false))[.size] as? Int else {
+ throw UploadError.streamReadFailed
+ }
+ let contentLength = preamble.count + fileSize + epilogue.count
+
+ let fileHandle = try FileHandle(forReadingFrom: fileURL)
+
+ var readStream: InputStream?
+ var writeStream: OutputStream?
+ Stream.getBoundStreams(withBufferSize: 65_536, inputStream: &readStream, outputStream: &writeStream)
+
+ guard let inputStream = readStream, let outputStream = writeStream else {
+ try? fileHandle.close()
+ throw UploadError.streamReadFailed
+ }
+
+ outputStream.open()
+
+ // OutputStream is not Sendable but is safely transferred to the
+ // writer thread — only the thread accesses it after this point.
+ nonisolated(unsafe) let output = outputStream
+
+ Thread.detachNewThread { [preamble] in
+ defer {
+ output.close()
+ try? fileHandle.close()
+ }
+ _ = Self.writeMultipartBody(
+ fileHandle: fileHandle, fileSize: fileSize,
+ preamble: preamble, epilogue: epilogue, to: output
+ )
+ }
+
+ return (inputStream, contentLength)
+ }
+
+ /// Writes the multipart body — preamble, then the file's bytes, then the
+ /// closing boundary — to `output`, returning `true` only if all of it was
+ /// written.
+ ///
+ /// Returns `false` **without** writing the closing boundary if the file can't
+ /// be fully read: a mid-stream read error, or the file ending short of the
+ /// `fileSize` the caller measured (it shrank since). The request's
+ /// Content-Length reflects that measured size, so a short body can't be
+ /// dressed up as a complete multipart — it fails the upload rather than
+ /// silently corrupting it, and the real cause is logged instead of swallowed.
+ /// (`false` is also returned on a write failure — e.g. the consumer closing
+ /// the stream — matching the preamble/chunk write checks.)
+ static func writeMultipartBody(
+ fileHandle: FileHandle,
+ fileSize: Int,
+ preamble: Data,
+ epilogue: Data,
+ to output: OutputStream
+ ) -> Bool {
+ guard writeAll(preamble, to: output) else { return false }
+
+ var remaining = fileSize
+ while remaining > 0 {
+ let chunkSize = min(65_536, remaining)
+ let chunk: Data
+ do {
+ chunk = try fileHandle.read(upToCount: chunkSize) ?? Data()
+ } catch {
+ Logger.mediaUpload.error("Reading the upload file failed mid-stream: \(error)")
+ return false
+ }
+ guard !chunk.isEmpty else {
+ // The file ended before `fileSize` bytes — it shrank since we
+ // measured it. Abort rather than emit a truncated multipart.
+ Logger.mediaUpload.error("Upload file ended \(remaining) bytes short of its measured size")
+ return false
+ }
+ guard writeAll(chunk, to: output) else { return false }
+ remaining -= chunk.count
+ }
+
+ return writeAll(epilogue, to: output)
+ }
+
+ /// Escapes a client-supplied value for a quoted `Content-Disposition`
+ /// parameter (`name`/`filename`). Percent-encodes CR, LF, and `"` so a crafted
+ /// filename or field name can't break the header line or inject an extra
+ /// multipart part — matching WHATWG's `multipart/form-data` field serialization.
+ private static func escapeQuotedParameter(_ value: String) -> String {
+ value
+ .replacingOccurrences(of: "\r", with: "%0D")
+ .replacingOccurrences(of: "\n", with: "%0A")
+ .replacingOccurrences(of: "\"", with: "%22")
+ }
+
+ /// Writes all bytes of `data` to the output stream, handling partial writes.
+ private static func writeAll(_ data: Data, to output: OutputStream) -> Bool {
+ data.withUnsafeBytes { buffer in
+ guard let base = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return false }
+ var written = 0
+ while written < data.count {
+ let result = output.write(base.advanced(by: written), maxLength: data.count - written)
+ if result <= 0 { return false }
+ written += result
+ }
+ return true
+ }
+ }
+}
diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaFileManager.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaFileManager.swift
index 8d00208cd..6c5965884 100644
--- a/ios/Sources/GutenbergKit/Sources/Media/MediaFileManager.swift
+++ b/ios/Sources/GutenbergKit/Sources/Media/MediaFileManager.swift
@@ -16,18 +16,38 @@ actor MediaFileManager {
private let rootURL: URL
private let uploadsDirectory: URL
- init(rootURL: URL = URL.libraryDirectory.appendingPathComponent("GutenbergKit")) {
+ /// Where imported media lives: `gbk-media-file:///Uploads/` names
+ /// `/Uploads/`.
+ static let defaultRootURL = URL.libraryDirectory.appendingPathComponent("GutenbergKit")
+
+ init(rootURL: URL = MediaFileManager.defaultRootURL) {
self.rootURL = rootURL
self.uploadsDirectory = self.rootURL.appendingPathComponent("Uploads")
Task {
await cleanupOldFiles()
+ Self.removeStaleWebKitUploadCopies()
}
}
- /// Imports a photo picker item and saves it to the uploads directory.
+ /// Imports a photo picker item into the uploads directory.
+ ///
+ /// Asks Photos for the item as a file and copies it, which on APFS is a clone: a
+ /// 1 GB video costs neither memory nor disk. The file keeps the name Photos gave
+ /// it, so the attachment WordPress creates is named after it. An item Photos can
+ /// only hand over as data is written from memory instead.
///
/// - Returns: MediaInfo with a `gbk-media-file://` URL and detected media type
func `import`(_ item: PhotosPickerItem) async throws -> MediaInfo {
+ do {
+ if let picked = try await item.loadTransferable(type: PickedFile.self) {
+ defer { try? fileManager.removeItem(at: picked.url.deletingLastPathComponent()) }
+ let imported = try adopt(picked.url, mimeType: picked.mimeType ?? item.supportedContentTypes.first?.preferredMIMEType)
+ return imported.mediaInfo
+ }
+ } catch {
+ Logger.media.error("Failed to load picker item \(item.supportedContentTypes) as a file, loading its data instead: \(error)")
+ }
+
let data: Data?
do {
data = try await item.loadTransferable(type: Data.self)
@@ -46,6 +66,50 @@ actor MediaFileManager {
return MediaInfo(url: fileURL.absoluteString, type: contentType?.preferredMIMEType)
}
+ /// Imports a file that is already on disk — a video the camera recorded — by
+ /// copying it into the uploads directory under its own name.
+ func importFile(at url: URL) throws -> MediaInfo {
+ try adopt(url, mimeType: nil).mediaInfo
+ }
+
+ /// Copies `url` into its own directory under the uploads directory.
+ private func adopt(_ url: URL, mimeType: String?) throws -> ImportedMedia {
+ let directory = uploadsDirectory.appending(component: UUID().uuidString, directoryHint: .isDirectory)
+ try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
+ let destination = directory.appending(component: MediaUploadSessionStore.sanitizeFilename(url.lastPathComponent))
+ try fileManager.copyItem(at: url, to: destination)
+ return ImportedMedia(
+ fileURL: destination,
+ mimeType: mimeType ?? Self.mimeType(forExtension: destination.pathExtension),
+ mediaURL: try Self.mediaURL(forPath: "/Uploads/\(directory.lastPathComponent)/\(destination.lastPathComponent)")
+ )
+ }
+
+ /// A `gbk-media-file:` URL for a path under the root, percent-encoded so a
+ /// filename with spaces or non-ASCII characters survives.
+ nonisolated static func mediaURL(forPath path: String) throws -> URL {
+ var components = URLComponents()
+ components.scheme = MediaFileSchemeHandler.scheme
+ components.host = ""
+ components.path = path
+ guard let url = components.url else { throw URLError(.badURL) }
+ return url
+ }
+
+ /// The `gbk-media-file:` URL that names `fileURL`, or `nil` when the file isn't
+ /// under `root` — the inverse of ``fileURL(for:root:)``.
+ nonisolated static func mediaURL(forFile fileURL: URL, root: URL = defaultRootURL) -> URL? {
+ let rootPath = root.standardizedFileURL.path(percentEncoded: false)
+ let filePath = fileURL.standardizedFileURL.path(percentEncoded: false)
+ let prefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/"
+ guard filePath.hasPrefix(prefix) else { return nil }
+ return try? mediaURL(forPath: "/" + filePath.dropFirst(prefix.count))
+ }
+
+ nonisolated static func mimeType(forExtension pathExtension: String) -> String {
+ UTType(filenameExtension: pathExtension)?.preferredMIMEType ?? "application/octet-stream"
+ }
+
/// Saves media data to the uploads directory and returns a URL with a
/// custom scheme.
func writeData(_ data: Data, withExtension ext: String) async throws -> URL {
@@ -58,11 +122,16 @@ actor MediaFileManager {
return URL(string: "\(MediaFileSchemeHandler.scheme):///Uploads/\(fileName)")!
}
- /// Gets URLResponse and data for a `gbk-media-file` URL
- func getData(for url: URL) async throws -> Data {
- // Convert `gbk-media-file:///Uploads/filename.jpg` to actual file path
- let fileURL = rootURL.appendingPathComponent(url.path)
- return try Data(contentsOf: fileURL)
+ /// The file a `gbk-media-file` URL names, or `nil` when the URL's path would
+ /// resolve outside `root` — a `..` segment, for instance, which the page controls.
+ nonisolated static func fileURL(for url: URL, root: URL = defaultRootURL) -> URL? {
+ let rootPath = root.standardizedFileURL.path(percentEncoded: false)
+ let candidate = root.appending(path: url.path(percentEncoded: false)).standardizedFileURL
+ let prefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/"
+ guard candidate.path(percentEncoded: false).hasPrefix(prefix) else {
+ return nil
+ }
+ return candidate
}
/// Cleans up files older than 2 days
@@ -90,3 +159,73 @@ actor MediaFileManager {
}
}
}
+
+extension MediaFileManager {
+ /// How long WebKit's copy of an uploaded file is kept.
+ static let webKitUploadCopyLifetime: TimeInterval = 2 * 24 * 60 * 60
+
+ /// Deletes the copies WebKit made of files handed to a file input, once they are
+ /// older than `age`.
+ ///
+ /// WebKit copies every file a file input receives — from the system picker or from
+ /// ``NativeFileInput`` — into `tmp/WKFileUploadPanel-…`, and never deletes it. The
+ /// copy is a clone, so it costs nothing while the original exists; once the
+ /// original is gone it holds the file's storage on its own. An upload in flight
+ /// reads from that copy, so only old ones are removed.
+ nonisolated static func removeStaleWebKitUploadCopies(
+ in directory: URL = FileManager.default.temporaryDirectory,
+ olderThan age: TimeInterval = webKitUploadCopyLifetime
+ ) {
+ let cutoff = Date.now.addingTimeInterval(-age)
+ guard let entries = try? FileManager.default.contentsOfDirectory(
+ at: directory,
+ includingPropertiesForKeys: [.creationDateKey]
+ ) else { return }
+ for entry in entries where entry.lastPathComponent.hasPrefix("WKFileUploadPanel-") {
+ let created = (try? entry.resourceValues(forKeys: [.creationDateKey]))?.creationDate
+ if let created, created < cutoff {
+ try? FileManager.default.removeItem(at: entry)
+ }
+ }
+ }
+}
+
+/// A file imported into the uploads directory.
+struct ImportedMedia: Sendable {
+ let fileURL: URL
+ let mimeType: String
+ /// The file's `gbk-media-file:` URL.
+ let mediaURL: URL
+
+ var mediaInfo: MediaInfo {
+ MediaInfo(url: mediaURL.absoluteString, type: mimeType)
+ }
+}
+
+/// A picker item received as a file.
+///
+/// Photos deletes the file it hands over once the import closure returns, so the
+/// closure copies it — a clone on APFS — into a staging directory first.
+/// Representations are tried in order; `.data` catches everything else.
+private struct PickedFile: Transferable {
+ let url: URL
+ let mimeType: String?
+
+ static var transferRepresentation: some TransferRepresentation {
+ FileRepresentation(importedContentType: .movie) { try stage($0, as: .movie) }
+ FileRepresentation(importedContentType: .image) { try stage($0, as: .image) }
+ FileRepresentation(importedContentType: .audio) { try stage($0, as: .audio) }
+ FileRepresentation(importedContentType: .data) { try stage($0, as: nil) }
+ }
+
+ private static func stage(_ received: ReceivedTransferredFile, as type: UTType?) throws -> PickedFile {
+ let directory = FileManager.default.temporaryDirectory
+ .appending(component: "GutenbergKit-imports", directoryHint: .isDirectory)
+ .appending(component: UUID().uuidString, directoryHint: .isDirectory)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let destination = directory.appending(component: received.file.lastPathComponent)
+ try FileManager.default.copyItem(at: received.file, to: destination)
+ let fromExtension = UTType(filenameExtension: destination.pathExtension)
+ return PickedFile(url: destination, mimeType: (fromExtension ?? type)?.preferredMIMEType)
+ }
+}
diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaFileSchemeHandler.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaFileSchemeHandler.swift
index 57ae12754..f637c3a9d 100644
--- a/ios/Sources/GutenbergKit/Sources/Media/MediaFileSchemeHandler.swift
+++ b/ios/Sources/GutenbergKit/Sources/Media/MediaFileSchemeHandler.swift
@@ -1,52 +1,136 @@
import Foundation
+import OSLog
+import UniformTypeIdentifiers
import WebKit
-/// Handles `gbk-media-file://` URL scheme requests in WKWebView.
-/// Serves media files from MediaFileManager with appropriate CORS headers.
+/// Serves imported media to the editor's page over `gbk-media-file:` URLs.
+///
+/// Streams each file in chunks rather than loading it: a whole-file read held the file
+/// in memory twice in the app, and a 1 GB video pushed WebKit's page process past its
+/// 2 GB limit before the page could read it.
+///
+/// Tracks every request it is serving, held strongly, so it never answers one WebKit
+/// has stopped — the page cancelled its `fetch`, or went away. Answering a stopped task
+/// raises an Objective-C exception.
+@MainActor
final class MediaFileSchemeHandler: NSObject, WKURLSchemeHandler {
- /// The custom URL scheme handled by this class
nonisolated static let scheme = "gbk-media-file"
- func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
- guard let url = urlSchemeTask.request.url else {
- urlSchemeTask.didFailWithError(URLError(.badURL))
+ /// How much of a file each `didReceive` carries.
+ static let chunkSize = 1024 * 1024
+
+ private let rootURL: URL
+ private var active: [ObjectIdentifier: ActiveRequest] = [:]
+
+ private struct ActiveRequest {
+ let task: any WKURLSchemeTask
+ var work: Task?
+ }
+
+ init(rootURL: URL = MediaFileManager.defaultRootURL) {
+ self.rootURL = rootURL
+ }
+
+ func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
+ start(urlSchemeTask)
+ }
+
+ func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {
+ stop(urlSchemeTask)
+ }
+
+ /// `webView(_:start:)` without the web view, for tests.
+ func start(_ task: any WKURLSchemeTask) {
+ guard let url = task.request.url,
+ let fileURL = MediaFileManager.fileURL(for: url, root: rootURL) else {
+ task.didFailWithError(URLError(.badURL))
return
}
- Task {
+ let key = ObjectIdentifier(task)
+ active[key] = ActiveRequest(task: task, work: nil)
+ active[key]?.work = Task { [weak self] in
+ await self?.serve(fileURL, to: key)
+ }
+ }
+
+ /// `webView(_:stop:)` without the web view, for tests.
+ func stop(_ task: any WKURLSchemeTask) {
+ active.removeValue(forKey: ObjectIdentifier(task))?.work?.cancel()
+ }
+
+ var activeRequestCount: Int { active.count }
+
+ private func serve(_ fileURL: URL, to key: ObjectIdentifier) async {
+ let reader: ChunkedFileReader
+ do {
+ reader = try await ChunkedFileReader.open(fileURL)
+ } catch {
+ fail(key, with: error)
+ return
+ }
+ defer { reader.close() }
+
+ guard let url = active[key]?.task.request.url,
+ let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: [
+ "Content-Type": UTType(filenameExtension: fileURL.pathExtension)?.preferredMIMEType ?? "application/octet-stream",
+ "Content-Length": "\(reader.size)",
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
+ "Access-Control-Allow-Headers": "*",
+ "Cache-Control": "no-cache",
+ ]) else {
+ fail(key, with: URLError(.badServerResponse))
+ return
+ }
+ guard let task = active[key]?.task else { return }
+ task.didReceive(response)
+
+ while true {
+ let chunk: Data
do {
- let (response, data) = try await getResponse(for: url)
- urlSchemeTask.didReceive(response)
- urlSchemeTask.didReceive(data)
- urlSchemeTask.didFinish()
+ chunk = try await reader.read(upToCount: Self.chunkSize)
} catch {
- urlSchemeTask.didFailWithError(error)
+ fail(key, with: error)
+ return
}
+ // Stopped while the chunk was read: WebKit raises if it is answered now.
+ guard let task = active[key]?.task else { return }
+ if chunk.isEmpty {
+ active.removeValue(forKey: key)
+ task.didFinish()
+ return
+ }
+ task.didReceive(chunk)
}
}
- private func getResponse(for url: URL) async throws -> (URLResponse, Data) {
- let data = try await MediaFileManager.shared.getData(for: url)
+ private func fail(_ key: ObjectIdentifier, with error: any Error) {
+ guard let request = active.removeValue(forKey: key) else { return }
+ Logger.media.error("Failed to serve \(request.task.request.url?.absoluteString ?? "?"): \(error)")
+ request.task.didFailWithError(error)
+ }
+}
- let headers = [
- "Access-Control-Allow-Origin": "*",
- "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
- "Access-Control-Allow-Headers": "*",
- "Cache-Control": "no-cache"
- ]
+/// Reads a file off the main actor, a chunk at a time.
+private final class ChunkedFileReader: @unchecked Sendable {
+ private let handle: FileHandle
+ let size: Int
- guard let response = HTTPURLResponse(
- url: url,
- statusCode: 200,
- httpVersion: "HTTP/1.1",
- headerFields: headers
- ) else {
- throw URLError(.unknown)
- }
+ private init(handle: FileHandle, size: Int) {
+ self.handle = handle
+ self.size = size
+ }
+
+ static func open(_ url: URL) async throws -> ChunkedFileReader {
+ let size = try FileManager.default.attributesOfItem(atPath: url.path(percentEncoded: false))[.size] as? Int ?? 0
+ return ChunkedFileReader(handle: try FileHandle(forReadingFrom: url), size: size)
+ }
- return (response, data)
+ func read(upToCount count: Int) async throws -> Data {
+ try handle.read(upToCount: count) ?? Data()
}
- func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
- // Nothing to do here for simple file serving
+ func close() {
+ try? handle.close()
}
}
diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaHandlers.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaHandlers.swift
index 9539f1aa9..c71a30658 100644
--- a/ios/Sources/GutenbergKit/Sources/Media/MediaHandlers.swift
+++ b/ios/Sources/GutenbergKit/Sources/Media/MediaHandlers.swift
@@ -58,8 +58,8 @@ public enum ProcessedProxyFile: Sendable {
///
/// Deliberately **not** class-bound. ``EditorViewController`` holds its processor
/// strongly for its own lifetime, so a conformer that holds the view controller back
-/// closes a retain cycle ARC cannot break — neither object is freed, and the editor
-/// stops tearing down its upload server. Dropping the class requirement lets you
+/// closes a retain cycle ARC cannot break — neither object is freed. Dropping the
+/// class requirement lets you
/// conform with a `struct` capturing only what the transform needs, which is the
/// shape that avoids this; a class bound invited the opposite. Note a
/// value type is not automatic protection — a `struct` that stores the view
diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaServerCredentials.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaServerCredentials.swift
index 2b2514e0a..d3cde41f4 100644
--- a/ios/Sources/GutenbergKit/Sources/Media/MediaServerCredentials.swift
+++ b/ios/Sources/GutenbergKit/Sources/Media/MediaServerCredentials.swift
@@ -43,20 +43,14 @@ enum MediaServerCredentials {
/// What makes it a *trap* rather than a warning is that the configuration is
/// incoherent, not merely unlucky: an uploader's media deletes still relay through
/// the internal media client, so there is no site root and auth header under which
- /// this host's uploader could have worked. Contrast the conditions the host's
- /// environment imposes at server start — a network policy that blocks the loopback
- /// endpoint, a port that won't bind — which log and degrade, because the very same
- /// configuration works once the environment allows it. Dropping the uploader is the
- /// symptom both share; only this one has a cause the host can fix in the
- /// configuration it just handed over.
+ /// this host's uploader could have worked. Contrast a processor without
+ /// credentials, which logs and degrades: its uploads still reach WordPress, through
+ /// the web view. Dropping the handler is the symptom both share; only this one has a
+ /// cause the host can fix in the configuration it just handed over.
///
- /// Called from `EditorViewController.init`, not from the server start. The uploader
- /// is `private(set)` and assigned only there, so a non-nil uploader at load time was
- /// necessarily passed at `init` — checking it then puts the host's own call site in
- /// the stack trace, instead of surfacing the mistake later from inside a page-load
- /// callback where the trace names only GutenbergKit. This mirrors what moving the
- /// handlers into `init` already did for the set-before-load contract: enforce the
- /// rule where the host states its intent.
+ /// Called from `EditorViewController.init`, where the host hands the uploader over,
+ /// so the host's own call site is in the stack trace instead of a page-load
+ /// callback that names only GutenbergKit.
///
/// (Android enforces this in `GutenbergView.mediaUploader`'s setter — the earliest
/// point available there, since it takes its handlers as mutable properties rather
diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadSchemeHandler.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadSchemeHandler.swift
new file mode 100644
index 000000000..c2036e9c5
--- /dev/null
+++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadSchemeHandler.swift
@@ -0,0 +1,318 @@
+import Foundation
+import OSLog
+import WebKit
+
+/// Receives the editor's media uploads over a `gbk-upload:` URL scheme and delivers
+/// them to WordPress through a ``MediaUploading`` service.
+///
+/// The page's `nativeMediaUploadMiddleware` sends a file in chunks rather than as one
+/// request: WebKit hands a scheme handler only bodies it has already buffered, and it
+/// drops a `Blob` body — including a `FormData` that holds one — without an error. An
+/// `ArrayBuffer` of a few megabytes always arrives. The protocol:
+///
+/// | Request | Body | Response |
+/// |---|---|---|
+/// | `POST gbk-upload://upload/sessions` | JSON `{filename, mimeType, size}` | `201 {"id"}` |
+/// | `POST …/sessions//chunks?offset=N` | the chunk's bytes | `200 {"received"}` |
+/// | `POST …/sessions//finish` | JSON `{fields, query}` | WordPress's response, verbatim |
+/// | `POST …/sessions//cancel` | — | `204` |
+/// | `POST …/media//delete` | JSON `{query}` | WordPress's response, verbatim |
+///
+/// Only the editor's own web view can load this scheme, so there is no token to check.
+/// Every response carries CORS headers: under Lockdown Mode WebKit enforces CORS on
+/// scheme responses too, and core's upload middleware reads
+/// `x-wp-upload-attachment-id` off a failed upload to recover it.
+@MainActor
+final class MediaUploadSchemeHandler: NSObject, WKURLSchemeHandler {
+ nonisolated static let scheme = "gbk-upload"
+
+ /// How long a session may sit without a chunk before it is abandoned.
+ static let sessionIdleTimeout: TimeInterval = 3600
+
+ let store: MediaUploadSessionStore
+ private var service: (any MediaUploading)?
+
+ /// Every request WebKit has started and not stopped, held strongly: a finished
+ /// task's identity can be reused by the next one, so tracking tasks by identifier
+ /// alone answers the wrong request.
+ private var active: [ObjectIdentifier: ActiveRequest] = [:]
+
+ private struct ActiveRequest {
+ let task: any WKURLSchemeTask
+ var work: Task?
+ }
+
+ init(service: (any MediaUploading)?, store: MediaUploadSessionStore = MediaUploadSessionStore()) {
+ self.service = service
+ self.store = store
+ super.init()
+ Task.detached(priority: .utility) {
+ MediaUploadSessionStore.removeAbandonedStaging()
+ }
+ }
+
+ /// Whether uploads are accepted. `false` once ``disable()`` has run, or when the
+ /// editor had no media handling to begin with.
+ var isEnabled: Bool { service != nil }
+
+ /// Stops accepting uploads, cancels the ones in flight, and deletes every staged
+ /// file. Requests after this get a `503`, which the page takes as the cue to upload
+ /// through the web view instead.
+ func disable() {
+ service = nil
+ for request in active.values {
+ request.work?.cancel()
+ }
+ let store = store
+ Task { await store.removeAll() }
+ }
+
+ // MARK: - WKURLSchemeHandler
+
+ func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
+ start(urlSchemeTask)
+ }
+
+ func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {
+ stop(urlSchemeTask)
+ }
+
+ /// `webView(_:start:)` without the web view, for tests.
+ func start(_ task: any WKURLSchemeTask) {
+ let key = ObjectIdentifier(task)
+ let request = task.request
+ active[key] = ActiveRequest(task: task, work: nil)
+ active[key]?.work = Task { [weak self] in
+ guard let self else { return }
+ let response = await self.response(to: request)
+ self.reply(to: key, with: response)
+ }
+ }
+
+ /// `webView(_:stop:)` without the web view, for tests.
+ ///
+ /// WebKit stops a task when the page aborts its `fetch` or goes away. Cancelling the
+ /// work cancels an upload to WordPress in flight — nobody is left to read its
+ /// response — and dropping the task keeps `reply` from answering it: answering a
+ /// stopped task raises an Objective-C exception.
+ func stop(_ task: any WKURLSchemeTask) {
+ active.removeValue(forKey: ObjectIdentifier(task))?.work?.cancel()
+ }
+
+ var activeRequestCount: Int { active.count }
+
+ // MARK: - Routing
+
+ private func response(to request: URLRequest) async -> SchemeResponse {
+ let route = Route(request)
+ if case .preflight = route {
+ return SchemeResponse(status: 204)
+ }
+ guard let service else {
+ return .error(503, code: "native_upload_unavailable", message: "Native media uploads are not available in this editor.")
+ }
+ do {
+ switch route {
+ case .beginSession:
+ let body = try Self.decode(BeginRequest.self, from: request)
+ await store.sweep(idleFor: Self.sessionIdleTimeout)
+ let id = try await store.begin(filename: body.filename, mimeType: body.mimeType, expectedSize: body.size)
+ return .json(201, ["id": id])
+
+ case let .appendChunk(id, offset):
+ guard let data = request.httpBody, !data.isEmpty else {
+ // WebKit delivers an `ArrayBuffer` body; an empty one here means the
+ // page sent something WebKit dropped, and appending nothing would
+ // leave the file short without anyone noticing.
+ return .error(400, code: "native_upload_empty_chunk", message: "The upload chunk had no body.")
+ }
+ let received = try await store.append(data, to: id, at: offset)
+ return .json(200, ["received": received])
+
+ case let .finishSession(id):
+ let body = try Self.decode(FinishRequest.self, from: request)
+ let finished = try await store.take(id)
+ defer { finished.cleanUp() }
+ let result = try await withBackgroundActivity("gutenbergkit-media-upload") {
+ try await service.upload(finished.file, fields: body.fields, query: body.query)
+ }
+ return .relay(result)
+
+ case let .cancelSession(id):
+ await store.discard(id)
+ return SchemeResponse(status: 204)
+
+ case let .deleteMedia(attachmentId):
+ let body = try Self.decode(DeleteRequest.self, from: request)
+ return .relay(try await service.delete(attachmentId: attachmentId, query: body.query))
+
+ case .preflight:
+ return SchemeResponse(status: 204)
+
+ case .unknown:
+ return .error(404, code: "native_upload_not_found", message: "Unknown native upload request.")
+ }
+ } catch let failure as MediaUploadSessionStore.Failure {
+ return Self.response(for: failure)
+ } catch is DecodingError {
+ return .error(400, code: "native_upload_bad_request", message: "The native upload request was malformed.")
+ } catch is CancellationError {
+ // Stopped by the page, or by `disable()`. A stopped task drops this reply.
+ return .error(503, code: "native_upload_cancelled", message: "The upload was cancelled.")
+ } catch {
+ if Task.isCancelled {
+ return .error(503, code: "native_upload_cancelled", message: "The upload was cancelled.")
+ }
+ Logger.mediaUpload.error("Native upload failed: \(error)")
+ return .error(500, code: "upload_error", message: error.localizedDescription)
+ }
+ }
+
+ private func reply(to key: ObjectIdentifier, with response: SchemeResponse) {
+ guard let request = active.removeValue(forKey: key) else {
+ return // Stopped: WebKit raises if a stopped task is answered.
+ }
+ let task = request.task
+ guard let url = task.request.url,
+ let httpResponse = HTTPURLResponse(
+ url: url,
+ statusCode: response.status,
+ httpVersion: "HTTP/1.1",
+ headerFields: response.headers.merging(Self.corsHeaders) { current, _ in current }
+ ) else {
+ task.didFailWithError(URLError(.badServerResponse))
+ return
+ }
+ task.didReceive(httpResponse)
+ if !response.body.isEmpty {
+ task.didReceive(response.body)
+ }
+ task.didFinish()
+ }
+
+ // MARK: - Helpers
+
+ private static let corsHeaders: [String: String] = [
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "*",
+ // Core's upload middleware reads this off a failed upload to recover it; a
+ // CORS response hides every header it doesn't list.
+ "Access-Control-Expose-Headers": "x-wp-upload-attachment-id",
+ "Cache-Control": "no-store",
+ ]
+
+ private static func response(for failure: MediaUploadSessionStore.Failure) -> SchemeResponse {
+ switch failure {
+ case .unknownSession:
+ return .error(404, code: "native_upload_session_not_found", message: "The upload session does not exist.")
+ case let .offsetMismatch(expected, offset):
+ return .error(409, code: "native_upload_offset_mismatch", message: "Expected a chunk at offset \(expected), got \(offset).")
+ case .tooLarge:
+ return .error(413, code: "upload_file_too_big", message: "The file is too large to upload in the editor.")
+ case let .incomplete(expected, received):
+ return .error(409, code: "native_upload_incomplete", message: "Received \(received) of \(expected) bytes.")
+ }
+ }
+
+ private static func decode(_ type: T.Type, from request: URLRequest) throws -> T {
+ try JSONDecoder().decode(T.self, from: request.httpBody ?? Data("{}".utf8))
+ }
+
+ private struct BeginRequest: Decodable {
+ let filename: String
+ let mimeType: String
+ let size: Int?
+ }
+
+ private struct FinishRequest: Decodable {
+ let fields: [MediaUploadField]
+ let query: String
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ fields = try container.decodeIfPresent([MediaUploadField].self, forKey: .fields) ?? []
+ query = try container.decodeIfPresent(String.self, forKey: .query) ?? ""
+ }
+
+ private enum CodingKeys: String, CodingKey { case fields, query }
+ }
+
+ private struct DeleteRequest: Decodable {
+ let query: String
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ query = try container.decodeIfPresent(String.self, forKey: .query) ?? ""
+ }
+
+ private enum CodingKeys: String, CodingKey { case query }
+ }
+
+ /// A request, by what it asks for.
+ private enum Route {
+ case beginSession
+ case appendChunk(id: String, offset: Int)
+ case finishSession(id: String)
+ case cancelSession(id: String)
+ case deleteMedia(attachmentId: String)
+ case preflight
+ case unknown
+
+ init(_ request: URLRequest) {
+ guard let url = request.url else { self = .unknown; return }
+ if request.httpMethod == "OPTIONS" { self = .preflight; return }
+ guard request.httpMethod == "POST" else { self = .unknown; return }
+
+ let parts = url.path(percentEncoded: false).split(separator: "/").map(String.init)
+ let offset = URLComponents(url: url, resolvingAgainstBaseURL: false)?
+ .queryItems?.first { $0.name == "offset" }?.value.flatMap(Int.init)
+
+ switch parts.count {
+ case 1 where parts[0] == "sessions":
+ self = .beginSession
+ case 3 where parts[0] == "sessions":
+ switch parts[2] {
+ case "chunks":
+ guard let offset, offset >= 0 else { self = .unknown; return }
+ self = .appendChunk(id: parts[1], offset: offset)
+ case "finish": self = .finishSession(id: parts[1])
+ case "cancel": self = .cancelSession(id: parts[1])
+ default: self = .unknown
+ }
+ case 3 where parts[0] == "media" && parts[2] == "delete" && !parts[1].isEmpty && parts[1].allSatisfy(\.isNumber):
+ self = .deleteMedia(attachmentId: parts[1])
+ default:
+ self = .unknown
+ }
+ }
+ }
+}
+
+/// A response for the scheme handler to send.
+struct SchemeResponse: Sendable {
+ let status: Int
+ var headers: [String: String] = [:]
+ var body = Data()
+
+ static func json(_ status: Int, _ object: [String: any Sendable]) -> SchemeResponse {
+ let body = (try? JSONSerialization.data(withJSONObject: object)) ?? Data()
+ return SchemeResponse(status: status, headers: ["Content-Type": "application/json"], body: body)
+ }
+
+ /// A WordPress-shaped error, `{code, message, data: {status}}`, so the page
+ /// surfaces it the way it surfaces WordPress's own.
+ static func error(_ status: Int, code: String, message: String) -> SchemeResponse {
+ json(status, ["code": code, "message": message, "data": ["status": status]])
+ }
+
+ /// WordPress's response verbatim: its status, body, and the headers worth
+ /// relaying. It is JSON unless WordPress said otherwise.
+ static func relay(_ response: MediaUploadResponse) -> SchemeResponse {
+ var headers = response.headers
+ if !headers.keys.contains(where: { $0.caseInsensitiveCompare("Content-Type") == .orderedSame }) {
+ headers["Content-Type"] = "application/json"
+ }
+ return SchemeResponse(status: response.statusCode, headers: headers, body: response.body)
+ }
+}
diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift
deleted file mode 100644
index ef17e707b..000000000
--- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift
+++ /dev/null
@@ -1,895 +0,0 @@
-import Foundation
-import GutenbergKitHTTP
-import OSLog
-
-/// A local HTTP server that receives file uploads from the WebView and routes
-/// them through the native media processing pipeline.
-///
-/// Built on ``HTTPServer`` from `GutenbergKitHTTP`, which handles TCP binding,
-/// HTTP parsing, bearer token authentication, and multipart form-data parsing.
-/// This class provides the upload-specific handler: receiving a file, delegating
-/// to the host app for processing/upload, and returning the result as JSON.
-///
-/// Lifecycle is tied to `EditorViewController` — start when the editor loads,
-/// stop on deinit.
-final class MediaUploadServer: Sendable {
-
- /// The port the server is listening on.
- let port: UInt16
-
- /// Per-session auth token for validating incoming requests.
- let token: String
-
- private let server: HTTPServer
-
- /// Sweeps crash-orphaned upload temp files off the editor-startup path.
- /// Exposed so tests can await completion. (Mirrors Android's `cleanupJob`.)
- let cleanupTask: Task
-
- /// Creates and starts a new upload server.
- ///
- /// - Parameters:
- /// - processor: Optional processor that transforms the file before delivery.
- /// - uploader: Optional host uploader that performs the upload on its own stack.
- /// - internalClient: GutenbergKit's own client for the configured site. Delivers
- /// uploads when no host uploader does, and every media delete.
- /// - maxRequestBodySize: The maximum allowed request body size in bytes.
- /// Requests exceeding this limit receive a 413 response. Defaults to 4 GB.
- static func start(
- processor: (any MediaProcessor)? = nil,
- uploader: (any MediaUploader)? = nil,
- internalClient: InternalMediaClient? = nil,
- maxRequestBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize
- ) async throws -> MediaUploadServer {
- // Sweep temp files orphaned by a prior crash, off the editor-startup
- // path — the sweep only deletes stale files (>1 hour old), so it cannot
- // race this server's own in-flight uploads and nothing below depends on it.
- let cleanupTask = Task.detached(priority: .utility) {
- cleanOrphanedUploads()
- }
-
- let handler = Handler(processor: processor, uploader: uploader, internalClient: internalClient)
-
- // A generous ceiling for receiving the upload body. The body read is
- // primarily bounded by the per-read idle timeout (which reaps a stalled
- // connection in seconds); this absolute backstop ensures a slow-but-steady
- // client can't hold a connection slot indefinitely. Ten minutes is far
- // beyond any realistic media upload over loopback while still bounding a
- // wedged one.
- let bodyReadTimeout: Duration = .seconds(600)
-
- let server = try await HTTPServer.start(
- name: "media-upload",
- requiresAuthentication: true,
- maxRequestBodySize: maxRequestBodySize,
- bodyReadTimeout: bodyReadTimeout,
- cors: .permissive,
- delegate: ServerDelegate(),
- handler: handler
- )
-
- let uploadServer = MediaUploadServer(server: server, cleanupTask: cleanupTask)
- #if DEBUG
- countServerStarted(processor: processor, uploader: uploader)
- #endif
- return uploadServer
- }
-
-#if DEBUG
- // MARK: - Leak Census (DEBUG)
-
- /// Counts live servers so a host that leaks editors finds out in its own debug build.
- ///
- /// Every live server is a bound loopback `NWListener`. There is one per editor and the
- /// editor stops it on `deinit`, so returning to zero is the normal outcome — monotone
- /// growth is the ownership cycle described on
- /// ``EditorViewController/stopMediaHandling()``. Nothing else produces it:
- /// `EditorViewController.warmup()` passes neither handler, so it never starts a server.
- ///
- /// This population is the only detectable symptom of that cycle. A `deinit` assertion
- /// on the editor cannot work — a cycle is precisely what stops `deinit` from running —
- /// and no UIKit callback distinguishes teardown from being covered or re-parented.
- ///
- /// Logged, never fatal. The threshold is a heuristic, and crashing a host's debug
- /// build over a heuristic is a worse trade than the leak it reports.
- private static let censusLock = NSLock()
- // Guarded by `censusLock` on every access.
- nonisolated(unsafe) private static var liveServerCount = 0
-
- /// Live servers tolerated before the count reads as a leak. Two editors can briefly
- /// overlap across a push or a modal transition; four is not a shape hosts produce.
- private static let liveServerLeakThreshold = 4
-
- private static func countServerStarted(
- processor: (any MediaProcessor)?,
- uploader: (any MediaUploader)?
- ) {
- let count = censusLock.withLock {
- liveServerCount += 1
- return liveServerCount
- }
-
- guard count >= liveServerLeakThreshold else { return }
-
- // Name every handler that was supplied, not just the first. With both set the
- // retainer is as likely to be the uploader, and naming only the processor sends
- // the reader to audit an object that may be a value type holding nothing at all.
- let names = [processor.map { String(describing: type(of: $0)) },
- uploader.map { String(describing: type(of: $0)) }].compactMap { $0 }
- let name = names.isEmpty ? "the host's media handler" : names.joined(separator: ", ")
- Logger.uploadServer.fault(
- """
- \(count, privacy: .public) media upload servers are live, one bound loopback \
- listener each. Editors are leaking: a host that both owns EditorViewController \
- and is one of its own media handlers (\(name, privacy: .public)) forms a retain \
- cycle ARC cannot break, so the editor's deinit never runs. Call \
- EditorViewController.stopMediaHandling() when you are done with the editor, or \
- keep the handler a leaf object that doesn't reference the editor.
- """
- )
- }
-
- deinit {
- Self.censusLock.withLock { Self.liveServerCount -= 1 }
- }
-#endif
-
- private init(server: HTTPServer, cleanupTask: Task) {
- self.server = server
- self.port = server.port
- self.token = server.token
- self.cleanupTask = cleanupTask
- }
-
- /// Stops the server and releases resources.
- func stop() {
- server.stop()
- }
-
- // MARK: - Request Handling
-
- /// Serves the upload server's requests.
- ///
- /// A `struct` rather than a closure over a context object: the dependencies become
- /// stored properties and the request logic becomes instance methods, instead of
- /// statics threading a context parameter through every call. It stores no reference
- /// back to the `MediaUploadServer`, so it can't close the
- /// `MediaUploadServer -> HTTPServer -> handler -> MediaUploadServer` loop that
- /// would keep `deinit` — and therefore `stop()` — from ever running. Being a value
- /// type is not what buys that: a `struct` storing the server would close the loop
- /// just the same, which is why the statics above stay static.
- ///
- /// Everything here is held **strongly**, so a processor that admitted a file for
- /// processing will process it, and an upload gated on a host uploader will be
- /// delivered by it — the reads within a request can't disagree, and an in-flight
- /// upload keeps the host's handlers alive until it unwinds. This matches Android,
- /// which holds its `processor`/`uploader` as plain `val`s for the same reason.
- ///
- /// Strong is safe because `EditorViewController` owns `mediaProcessor` and
- /// `mediaUploader` strongly too. A host object that retains the view controller
- /// back already forms `EditorViewController -> mediaUploader ->
- /// EditorViewController`, a cycle this handler can neither create nor prevent.
- ///
- /// Implicitly `Sendable`: `MediaProcessor` and `MediaUploader` are `Sendable`
- /// protocols and `InternalMediaClient` is `@unchecked Sendable`.
- private struct Handler: HTTPRequestHandler {
- let processor: (any MediaProcessor)?
- let uploader: (any MediaUploader)?
- let internalClient: InternalMediaClient?
-
- func handle(_ request: HTTPServer.Request) async -> HTTPResponse {
- let parsed = request.parsed
-
- // Routes: POST /upload, and DELETE /media/ for the editor's orphan
- // cleanup. (OPTIONS preflight is answered by the HTTP library under its
- // permissive CORS policy.) Match on the path alone — the target carries
- // a query string (e.g. `?_embed`, `?force=true`) relayed to WordPress.
- let method = parsed.method.uppercased()
-
- if method == "POST", parsed.path == "/upload" {
- return await handleUpload(request)
- }
-
- if method == "DELETE", let attachmentId = Self.attachmentId(fromPath: parsed.path) {
- return await handleDelete(attachmentId, query: parsed.query)
- }
-
- return MediaUploadServer.errorResponse(status: 404, message: "Not found")
- }
-
- private func handleUpload(_ request: HTTPServer.Request) async -> HTTPResponse {
- let parts: [MultipartPart]
- do {
- parts = try request.parsed.multipartParts()
- } catch {
- Logger.uploadServer.error("Multipart parse failed: \(error)")
- return MediaUploadServer.errorResponse(status: 400, message: "Expected multipart/form-data")
- }
-
- // Find the file part (the first part with a filename).
- guard let filePart = parts.first(where: { $0.filename != nil }) else {
- return MediaUploadServer.errorResponse(status: 400, message: "No file found in request")
- }
-
- // The non-file parts (post, additionalData) and the original query
- // (e.g. ?_embed) must reach WordPress too — relay them alongside the file.
- let extraParts = parts.filter { $0.filename == nil }
- let query = request.parsed.query
-
- let filename = filePart.filename ?? "upload"
- let mimeType = filePart.contentType
-
- // Ask the processor — from metadata alone — whether it will touch a file like
- // this. If not, forward the original upload to WordPress directly, skipping a
- // full temp-file copy of a file the processor won't process (e.g. a video handed
- // to an image-only processor).
- //
- // An uploader takes over delivery for *every* file, so with one set there is
- // no passthrough to fall to and the gate can't decline the upload outright.
- // It still decides whether `processFile` runs, though — a declined file is
- // handed to the uploader unprocessed rather than to a processor that said it
- // won't touch it — so the answer is carried into `processAndUpload` rather
- // than discarded here. Asked exactly once per upload, matching Android.
- let processorWantsFile = processor?.handlesFile(ofType: mimeType, named: filename) ?? false
- guard uploader != nil || processorWantsFile else {
- do {
- return try await passthroughResponse(request, query: query)
- } catch {
- return Self.uploadErrorResponse(error)
- }
- }
-
- // Someone wants the file — the processor, the uploader, or both. Stream the
- // part body to a dedicated temp file for them: the library's RequestBody may
- // be a byte-range slice of a larger temp file whose lifecycle is tied to ARC,
- // so they need a standalone file that outlives the handler return.
- let tempDir = MediaUploadServer.uploadsTempDirectory
- try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
-
- let fileURL = tempDir.appending(component: "\(UUID().uuidString)-\(MediaUploadServer.sanitizeFilename(filename))")
- do {
- let inputStream = try filePart.body.makeInputStream()
- try MediaUploadServer.writeStream(inputStream, to: fileURL)
- } catch {
- try? FileManager.default.removeItem(at: fileURL)
- Logger.uploadServer.error("Failed to write upload to disk: \(error)")
- return MediaUploadServer.errorResponse(status: 500, message: "Failed to save file")
- }
-
- // From here on always clean up the original temp file. The processed
- // file (if the processor produced a new one) is cleaned up inside
- // processAndUpload so its throw paths are covered too.
- defer { try? FileManager.default.removeItem(at: fileURL) }
-
- do {
- let uploadResult = try await processAndUpload(
- fileURL: fileURL, mimeType: mimeType, filename: filename,
- extraParts: extraParts, query: query,
- processorWantsFile: processorWantsFile
- )
- switch uploadResult {
- case .uploaded(let uploaded):
- Logger.uploadServer.debug("Uploaded file to WordPress")
- return Self.relayResponse(uploaded)
- case .passthrough:
- // The processor didn't modify the file — forward the original request
- // body to WordPress without re-encoding.
- return try await passthroughResponse(request, query: query)
- }
- } catch {
- return Self.uploadErrorResponse(error)
- }
- }
-
- /// Forwards the original request body to WordPress unchanged (no multipart
- /// re-encoding) and relays the response. Used when the processor won't touch
- /// the file — it declined by metadata (`handlesFile` returned false) or
- /// `processFile` returned `.original`.
- private func passthroughResponse(
- _ request: HTTPServer.Request, query: String
- ) async throws -> HTTPResponse {
- // As in `processAndUpload`: don't put bytes on the wire for a torn-down
- // editor, regardless of whether the HTTP client honors cancellation.
- try Task.checkCancellation()
-
- Logger.uploadServer.debug("Passthrough: forwarding original request body to WordPress")
- guard let body = request.parsed.body,
- let contentType = request.parsed.header("Content-Type"),
- let internalClient else {
- return MediaUploadServer.errorResponse(status: 500, message: UploadError.noUploader.localizedDescription)
- }
- let response = try await internalClient.passthroughUpload(body: body, contentType: contentType, query: query)
- return Self.relayResponse(response)
- }
-
- /// The attachment ID in a `/media/` path, or `nil` if the path is not one.
- ///
- /// Deliberately narrow: this server relays media operations, not arbitrary
- /// REST requests, so only a numeric attachment ID under `/media/` matches.
- private static func attachmentId(fromPath path: String) -> String? {
- let components = path.split(separator: "/", omittingEmptySubsequences: true)
- guard components.count == 2, components[0] == "media" else { return nil }
- let id = String(components[1])
- guard !id.isEmpty, id.allSatisfy(\.isNumber) else { return nil }
- return id
- }
-
- /// Relays the editor's orphan cleanup to WordPress.
- ///
- /// Core's media upload middleware deletes the attachment when every
- /// `post-process` retry fails. A cross-origin editor cannot issue that
- /// request directly — api-fetch tunnels `DELETE` as a `POST` carrying
- /// `X-HTTP-Method-Override`, which core's CORS allow-list omits, so the
- /// browser blocks it at preflight. Relaying it here lets the cleanup run.
- private func handleDelete(
- _ attachmentId: String, query: String
- ) async -> HTTPResponse {
- guard let internalClient else {
- return MediaUploadServer.errorResponse(status: 500, message: UploadError.noUploader.localizedDescription)
- }
- do {
- let response = try await internalClient.deleteMedia(attachmentId: attachmentId, query: query)
- return Self.relayResponse(response)
- } catch {
- return Self.uploadErrorResponse(error)
- }
- }
-
- /// Relays WordPress's exact status, body, and relayable headers to the editor
- /// so it sees the same attachment object (or error) as a direct upload.
- ///
- /// The headers matter for recovery: `x-wp-upload-attachment-id` is what lets
- /// the editor retry `post-process` for an upload whose metadata generation
- /// fataled server-side, rather than surfacing a permanent failure and
- /// leaving an orphaned attachment behind.
- ///
- /// The response's own `Content-Type` wins over the JSON default. `HTTPResponse`
- /// serializes every header it is given, so appending the default unconditionally
- /// would emit the name twice for a processor that sets it.
- private static func relayResponse(_ response: MediaUploadResponse) -> HTTPResponse {
- let hasContentType = response.headers.keys.contains { $0.lowercased() == "content-type" }
- return HTTPResponse(
- status: response.statusCode,
- headers: (hasContentType ? [] : [("Content-Type", "application/json")])
- + response.headers.map { ($0.key, $0.value) },
- body: response.body
- )
- }
-
- /// Builds the 500 response for a failed upload. A cancelled connection task
- /// (editor abort / server stop) surfaces here too — as CancellationError or
- /// URLError.cancelled — but isn't a failure and the server closes the
- /// connection without sending this response (see HTTPServer's cancellation
- /// check), so log that quietly.
- private static func uploadErrorResponse(_ error: any Error) -> HTTPResponse {
- if Task.isCancelled {
- Logger.uploadServer.debug("Upload cancelled")
- } else {
- Logger.uploadServer.error("Upload processing failed: \(error)")
- }
- return MediaUploadServer.errorResponse(status: 500, message: error.localizedDescription)
- }
-
- // MARK: - Processor Pipeline
-
- /// Result of the processing + upload pipeline.
- private enum UploadResult {
- /// The uploader or internal media client completed the upload;
- /// carries the raw WordPress response to relay.
- case uploaded(MediaUploadResponse)
- /// The processor didn't modify the file, so the original body is forwarded.
- /// The caller should forward the original request body to WordPress.
- case passthrough
- }
-
- private func processAndUpload(
- fileURL: URL, mimeType: String, filename: String,
- extraParts: [MultipartPart], query: String, processorWantsFile: Bool
- ) async throws -> UploadResult {
- // Step 1: Process (resize, transcode, etc.) — but only for a file the
- // processor's metadata gate accepted. `handlesFile` returning false is the
- // processor saying it won't touch a file like this, so handing it one anyway
- // would break the contract the gate documents. With an uploader set the file
- // still gets delivered; it just skips processing on its way there.
- let processed: ProcessedProxyFile
- if let processor, processorWantsFile {
- processed = try await processor.processFile(at: fileURL, mimeType: mimeType, filename: filename)
- } else {
- processed = .original
- }
-
- // Resolve the file to upload and its metadata. `.processed` uses the
- // processor's values verbatim, so a format change is reported to WordPress.
- let uploadURL: URL
- let uploadMimeType: String
- let uploadFilename: String
- switch processed {
- case .original:
- uploadURL = fileURL
- uploadMimeType = mimeType
- uploadFilename = filename
- case let .processed(url, processedMimeType, processedFilename):
- uploadURL = url
- uploadMimeType = processedMimeType
- uploadFilename = processedFilename
- }
-
- // The processed file (if the processor produced a new one) is ours to
- // clean up — on success it has been uploaded, on failure it is abandoned.
- // Cleaning up here rather than in the caller covers the throw paths too.
- defer {
- if uploadURL != fileURL {
- try? FileManager.default.removeItem(at: uploadURL)
- }
- }
-
- // The editor was torn down (or the client disconnected) while we processed.
- // Don't start an outbound upload whose response nobody will read — it would
- // create an attachment neither GutenbergKit nor the host knows to clean up.
- // Checking here rather than relying on the HTTP client to notice cancellation
- // keeps this true for a host-injected `URLSessionProtocol` that doesn't.
- try Task.checkCancellation()
-
- // Step 2: deliver. An uploader owns delivery on the host's own stack and
- // returns the finished attachment JSON (or throws); GutenbergKit relays that
- // as a success and never runs its own recovery behind it.
- if let uploader {
- let upload = MediaUpload(
- fileURL: uploadURL,
- mimeType: uploadMimeType,
- filename: uploadFilename,
- fields: try await Self.formFields(from: extraParts),
- query: query
- )
- let attachment = try await uploader.upload(upload)
- return .uploaded(MediaUploadResponse(statusCode: 201, body: attachment))
- }
-
- if let internalClient {
- // Unmodified — forward the original request body directly, skipping
- // multipart re-encoding.
- if case .original = processed {
- return .passthrough
- }
- let result = try await internalClient.upload(fileURL: uploadURL, mimeType: uploadMimeType, filename: uploadFilename, extraParts: extraParts, query: query)
- return .uploaded(result)
- } else {
- throw UploadError.noUploader
- }
- }
-
- /// The editor's non-file form parts as ordered, UTF-8-decoded fields.
- ///
- /// A list rather than a dictionary so repeated names (e.g. a `field[]` array)
- /// survive verbatim, in the order the editor sent them.
- ///
- /// This decode can't mangle anything, but only because of who is on the other end —
- /// nothing in the code enforces it. Three things have to stay true:
- ///
- /// 1. Only the editor's own web page can reach this server. It listens on loopback,
- /// and every request has to carry a per-session token.
- /// 2. Text the editor puts in a form field is already valid Unicode. The browser
- /// guarantees that when the value is set, so it cannot hand us bad bytes.
- /// 3. The only way a browser can put *raw* bytes in a form is a file or a Blob, and
- /// those always arrive with a filename. Anything with a filename is handled as
- /// the file, never as a field — so raw bytes never reach this decode.
- ///
- /// If one of those stops being true, bad bytes quietly turn into replacement
- /// characters, and the platforms don't even agree on how many: `ED A0 80` becomes
- /// three of them here and one on Android. There is no single behavior worth
- /// documenting, so the tests pin rule 3 instead.
- private static func formFields(from parts: [MultipartPart]) async throws -> [MediaUploadField] {
- var fields: [MediaUploadField] = []
- for part in parts {
- fields.append(MediaUploadField(name: part.name, value: String(decoding: try await part.body.data, as: UTF8.self)))
- }
- return fields
- }
- }
-
- private static func errorResponse(status: Int, message: String) -> HTTPResponse {
- // Emit a WordPress-REST-style error object so the JS middleware normalizes
- // it (and surfaces `message`) the same way it does a relayed WordPress
- // error — the local server's own errors need no special-casing.
- let payload = ["code": "upload_error", "message": message]
- let body = (try? JSONSerialization.data(withJSONObject: payload))
- ?? Data(#"{"code":"upload_error","message":"Upload failed"}"#.utf8)
- return HTTPResponse(
- status: status,
- headers: [("Content-Type", "application/json")],
- body: body
- )
- }
-
- /// Answers the server's recoverable parse errors (e.g. an over-limit body)
- /// with the same JSON `{code, message}` shape the editor expects, so the
- /// middleware surfaces a real message ("The file is too large…") instead of a
- /// generic parse-failure. A leaf object — the HTTP server retains it.
- private final class ServerDelegate: HTTPServerDelegate {
- func response(forRecoverableParseError error: HTTPRequestParseError) -> HTTPResponse {
- let message: String = switch error {
- case .payloadTooLarge: "The file is too large to upload in the editor."
- default: "\(error.httpStatusText)"
- }
- return MediaUploadServer.errorResponse(status: error.httpStatus, message: message)
- }
- }
-
- // MARK: - Helpers
-
- /// Directory for staging uploaded files, under the system temp dir.
- private static var uploadsTempDirectory: URL {
- FileManager.default.temporaryDirectory
- .appending(component: "GutenbergKit-uploads", directoryHint: .isDirectory)
- }
-
- /// Deletes upload temp files left behind by a prior crash. Files still in
- /// flight (only seconds old) are preserved by the age threshold, so this is
- /// safe even if another editor instance is mid-upload.
- private static func cleanOrphanedUploads() {
- let cutoff = Date(timeIntervalSinceNow: -3600) // 1 hour ago
- guard let files = try? FileManager.default.contentsOfDirectory(
- at: uploadsTempDirectory,
- includingPropertiesForKeys: [.contentModificationDateKey]
- ) else { return }
- for file in files {
- let modified = (try? file.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate
- if let modified, modified < cutoff {
- try? FileManager.default.removeItem(at: file)
- }
- }
- }
-
- /// Sanitizes a filename to prevent path traversal.
- private static func sanitizeFilename(_ name: String) -> String {
- let safe = (name as NSString).lastPathComponent
- .replacingOccurrences(of: "/", with: "")
- .replacingOccurrences(of: "\\", with: "")
- return safe.isEmpty ? "upload" : safe
- }
-
- /// Streams an InputStream to a file URL.
- private static func writeStream(_ inputStream: InputStream, to url: URL) throws {
- inputStream.open()
- defer { inputStream.close() }
-
- // `OutputStream(url:append:)` returns nil if the file can't be opened for
- // writing (e.g. the uploads directory was removed after it was created, or
- // a permissions/sandbox failure). Throw rather than force-unwrap so the
- // caller returns a clean 500 instead of trapping the process.
- guard let outputStream = OutputStream(url: url, append: false) else {
- throw UploadError.streamWriteFailed
- }
- outputStream.open()
- defer { outputStream.close() }
-
- let bufferSize = 65_536
- let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize)
- defer { buffer.deallocate() }
-
- // Use read() return value as the sole termination signal. Do NOT check
- // hasBytesAvailable — for piped streams (used by file-slice RequestBody),
- // it can return false before the writer thread has pumped the next chunk,
- // causing an early exit and a truncated file.
- while true {
- let bytesRead = inputStream.read(buffer, maxLength: bufferSize)
- if bytesRead < 0 {
- throw inputStream.streamError ?? UploadError.streamReadFailed
- }
- if bytesRead == 0 { break }
-
- var totalWritten = 0
- while totalWritten < bytesRead {
- let written = outputStream.write(buffer.advanced(by: totalWritten), maxLength: bytesRead - totalWritten)
- if written < 0 {
- throw outputStream.streamError ?? UploadError.streamWriteFailed
- }
- totalWritten += written
- }
- }
- }
-}
-
-// MARK: - Errors
-
-/// Errors from the native media upload pipeline.
-enum UploadError: Error, LocalizedError {
- case noUploader
- case streamReadFailed
- case streamWriteFailed
-
- var errorDescription: String? {
- switch self {
- case .noUploader: "No media uploader or internal media client configured"
- case .streamReadFailed: "Failed to read upload stream"
- case .streamWriteFailed: "Failed to write upload to disk"
- }
- }
-}
-
-// MARK: - Internal Media Client
-
-/// GutenbergKit's own client for the configured site, built from the site credentials
-/// in `EditorConfiguration`.
-///
-/// Not an implementation of any host-facing protocol — it is the thing that actually
-/// performs GutenbergKit's media requests. It delivers uploads the host did not take
-/// over, and relays the editor's media deletes: every attachment lives on the
-/// configured site, so that is where its deletion goes.
-class InternalMediaClient: @unchecked Sendable {
- private let httpClient: EditorHTTPClientProtocol
- private let siteApiRoot: URL
- private let siteApiNamespace: String?
-
- init(httpClient: EditorHTTPClientProtocol, siteApiRoot: URL, siteApiNamespace: [String] = []) {
- self.httpClient = httpClient
- self.siteApiRoot = siteApiRoot
- self.siteApiNamespace = siteApiNamespace.first
- }
-
- /// The WordPress media endpoint URL, built through the shared
- /// ``WordPressRESTURL`` namespacing (so it matches every other REST URL) and
- /// carrying the original request query (e.g. `?_embed`) through to WordPress.
- private func mediaEndpointURL(query: String, attachmentId: String? = nil) -> URL {
- let path = attachmentId.map { "/wp/v2/media/\($0)" } ?? "/wp/v2/media"
- let base = WordPressRESTURL.namespaced(apiRoot: siteApiRoot, path: path, namespace: siteApiNamespace)
- guard !query.isEmpty else { return base }
- // `query` is the raw request query in wire form (leading "?"). Set it via
- // `percentEncodedQuery` so a value that isn't URL-safe can't make
- // `URL(string:)` return nil and silently drop the query.
- var components = URLComponents(url: base, resolvingAgainstBaseURL: false)
- components?.percentEncodedQuery = String(query.dropFirst())
- return components?.url ?? base
- }
-
- func upload(fileURL: URL, mimeType: String, filename: String, extraParts: [MultipartPart], query: String) async throws -> MediaUploadResponse {
- let boundary = UUID().uuidString
-
- // Read the (small, text) non-file parts up front so the body builder
- // stays synchronous — the file itself is still streamed from disk.
- var extraFields: [(name: String, value: Data)] = []
- for part in extraParts {
- extraFields.append((part.name, try await part.body.data))
- }
-
- let (bodyStream, contentLength) = try Self.multipartBodyStream(
- fileURL: fileURL, boundary: boundary, filename: filename, mimeType: mimeType, extraFields: extraFields
- )
-
- var request = URLRequest(url: mediaEndpointURL(query: query))
- request.httpMethod = "POST"
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
- request.setValue("\(contentLength)", forHTTPHeaderField: "Content-Length")
- request.httpBodyStream = bodyStream
-
- return try await performUpload(request)
- }
-
- /// Forwards the original request body to WordPress without re-encoding.
- ///
- /// Used when the processor's `processFile` returned the file unchanged —
- /// the incoming multipart body is already valid for WordPress.
- func passthroughUpload(body: RequestBody, contentType: String, query: String) async throws -> MediaUploadResponse {
- var request = URLRequest(url: mediaEndpointURL(query: query))
- request.httpMethod = "POST"
- request.setValue(contentType, forHTTPHeaderField: "Content-Type")
- request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length")
- request.httpBodyStream = try body.makeInputStream()
-
- return try await performUpload(request)
- }
-
- /// Deletes an attachment, relaying WordPress's response verbatim.
- ///
- /// Carries the editor's query through unchanged — core's cleanup sends
- /// `?force=true`, without which WordPress trashes rather than deletes.
- func deleteMedia(attachmentId: String, query: String) async throws -> MediaUploadResponse {
- var request = URLRequest(url: mediaEndpointURL(query: query, attachmentId: attachmentId))
- request.httpMethod = "DELETE"
-
- // Authorization is applied centrally by the HTTP client, as for uploads.
- let (data, response) = try await httpClient.performRaw(request)
- return MediaUploadResponse(
- statusCode: response.statusCode,
- body: data,
- headers: Self.relayableHeaders(from: response)
- )
- }
-
- /// Sends the assembled upload request to WordPress and relays the response.
- ///
- /// The request body is a **one-shot** stream (a bound-pair pipe for the
- /// multipart re-encode and file-slice paths), so it can't be replayed. That
- /// only matters if URLSession has to resend the body — i.e. a `307`/`308`
- /// redirect that preserves the `POST`. `301`/`302`/`303` downgrade to a
- /// bodyless GET, and a Bearer-token `401` doesn't trigger a resend, so those
- /// never replay the stream. WordPress core never redirects `POST /wp/v2/media`;
- /// if a proxy or misconfiguration did, the resend would read the now-exhausted
- /// stream and send an empty body, which WordPress rejects — a clean failure,
- /// not a truncated attachment (the stream is consumed, never rewound). We
- /// intentionally don't implement `needNewBodyStream`, or buffer the body to a
- /// replayable file, for that rare case.
- private func performUpload(_ request: URLRequest) async throws -> MediaUploadResponse {
- // The body may be fed by a background writer thread via a bound stream pair
- // (multipartBodyStream, or RequestBody.makeInputStream for file slices). If
- // the request is cancelled or fails, URLSession may abandon the stream
- // without draining it, leaving that writer blocked forever on a full buffer
- // — leaking the thread and its open file handle. Closing the input stream on
- // every exit breaks the pair so the writer's write() fails and it unwinds.
- // (For in-memory/whole-file bodies there is no writer thread and this is a
- // harmless no-op.)
- defer { request.httpBodyStream?.close() }
-
- // Relay WordPress's response verbatim — including non-2xx statuses — so
- // the editor sees WordPress's real status and error body, exactly as a
- // direct upload would. `performRaw` does not throw on non-2xx.
- let (data, response) = try await httpClient.performRaw(request)
- return MediaUploadResponse(
- statusCode: response.statusCode,
- body: data,
- headers: Self.relayableHeaders(from: response)
- )
- }
-
- /// The upstream headers worth relaying to the editor.
- ///
- /// Deliberately an allowlist rather than a filtered passthrough: the body is
- /// re-sent with a recomputed length, so relaying upstream entity or
- /// transport headers wholesale would risk contradicting what the server
- /// actually sends.
- private static let relayableHeaderNames = ["x-wp-upload-attachment-id"]
-
- /// Picks the headers to relay out of an upstream response.
- private static func relayableHeaders(from response: HTTPURLResponse) -> [String: String] {
- var headers: [String: String] = [:]
- for name in relayableHeaderNames {
- if let value = response.value(forHTTPHeaderField: name) {
- headers[name] = value
- }
- }
- return headers
- }
-
- // MARK: - Streaming Multipart Body
-
- /// Builds a multipart/form-data body as an `InputStream` that streams the
- /// file from disk without loading it into memory.
- ///
- /// Uses a bound stream pair with a background writer thread — the same
- /// pattern as `RequestBody.makePipedFileSliceStream`.
- ///
- /// - Returns: A tuple of the input stream and the total content length.
- static func multipartBodyStream(
- fileURL: URL,
- boundary: String,
- filename: String,
- mimeType: String,
- extraFields: [(name: String, value: Data)]
- ) throws -> (InputStream, Int) {
- // The non-file parts (post, additionalData) go into the preamble ahead of the streamed
- // file; they're small, and `contentLength` counts them via `preamble.count`. Their
- // values are appended as raw bytes rather than through `String(data:encoding:)`, which
- // returns nil on bad UTF-8 — and the `?? ""` you'd reach for behind it would quietly
- // drop a whole field. Raw bytes also keep this re-encode byte-for-byte identical to
- // the plain passthrough it replaces. (Bad bytes can't get here; see `formFields`.)
- var preamble = Data()
- for field in extraFields {
- preamble.append(Data("--\(boundary)\r\n".utf8))
- preamble.append(Data("Content-Disposition: form-data; name=\"\(escapeQuotedParameter(field.name))\"\r\n\r\n".utf8))
- preamble.append(field.value)
- preamble.append(Data("\r\n".utf8))
- }
- preamble.append(Data("--\(boundary)\r\n".utf8))
- preamble.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(escapeQuotedParameter(filename))\"\r\n".utf8))
- // `mimeType` is a client-supplied Content-Type value; strip CR/LF so a
- // crafted value can't inject additional headers. (Quotes are legal in
- // Content-Type parameters, so they're left intact.)
- let safeMimeType = mimeType.replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: "")
- preamble.append(Data("Content-Type: \(safeMimeType)\r\n\r\n".utf8))
- let epilogue = Data("\r\n--\(boundary)--\r\n".utf8)
-
- guard let fileSize = try FileManager.default.attributesOfItem(atPath: fileURL.path(percentEncoded: false))[.size] as? Int else {
- throw UploadError.streamReadFailed
- }
- let contentLength = preamble.count + fileSize + epilogue.count
-
- let fileHandle = try FileHandle(forReadingFrom: fileURL)
-
- var readStream: InputStream?
- var writeStream: OutputStream?
- Stream.getBoundStreams(withBufferSize: 65_536, inputStream: &readStream, outputStream: &writeStream)
-
- guard let inputStream = readStream, let outputStream = writeStream else {
- try? fileHandle.close()
- throw UploadError.streamReadFailed
- }
-
- outputStream.open()
-
- // OutputStream is not Sendable but is safely transferred to the
- // writer thread — only the thread accesses it after this point.
- nonisolated(unsafe) let output = outputStream
-
- Thread.detachNewThread { [preamble] in
- defer {
- output.close()
- try? fileHandle.close()
- }
- _ = Self.writeMultipartBody(
- fileHandle: fileHandle, fileSize: fileSize,
- preamble: preamble, epilogue: epilogue, to: output
- )
- }
-
- return (inputStream, contentLength)
- }
-
- /// Writes the multipart body — preamble, then the file's bytes, then the
- /// closing boundary — to `output`, returning `true` only if all of it was
- /// written.
- ///
- /// Returns `false` **without** writing the closing boundary if the file can't
- /// be fully read: a mid-stream read error, or the file ending short of the
- /// `fileSize` the caller measured (it shrank since). The request's
- /// Content-Length reflects that measured size, so a short body can't be
- /// dressed up as a complete multipart — it fails the upload rather than
- /// silently corrupting it, and the real cause is logged instead of swallowed.
- /// (`false` is also returned on a write failure — e.g. the consumer closing
- /// the stream — matching the preamble/chunk write checks.)
- static func writeMultipartBody(
- fileHandle: FileHandle,
- fileSize: Int,
- preamble: Data,
- epilogue: Data,
- to output: OutputStream
- ) -> Bool {
- guard writeAll(preamble, to: output) else { return false }
-
- var remaining = fileSize
- while remaining > 0 {
- let chunkSize = min(65_536, remaining)
- let chunk: Data
- do {
- chunk = try fileHandle.read(upToCount: chunkSize) ?? Data()
- } catch {
- Logger.uploadServer.error("Reading the upload file failed mid-stream: \(error)")
- return false
- }
- guard !chunk.isEmpty else {
- // The file ended before `fileSize` bytes — it shrank since we
- // measured it. Abort rather than emit a truncated multipart.
- Logger.uploadServer.error("Upload file ended \(remaining) bytes short of its measured size")
- return false
- }
- guard writeAll(chunk, to: output) else { return false }
- remaining -= chunk.count
- }
-
- return writeAll(epilogue, to: output)
- }
-
- /// Escapes a client-supplied value for a quoted `Content-Disposition`
- /// parameter (`name`/`filename`). Percent-encodes CR, LF, and `"` so a crafted
- /// filename or field name can't break the header line or inject an extra
- /// multipart part — matching WHATWG's `multipart/form-data` field serialization.
- private static func escapeQuotedParameter(_ value: String) -> String {
- value
- .replacingOccurrences(of: "\r", with: "%0D")
- .replacingOccurrences(of: "\n", with: "%0A")
- .replacingOccurrences(of: "\"", with: "%22")
- }
-
- /// Writes all bytes of `data` to the output stream, handling partial writes.
- private static func writeAll(_ data: Data, to output: OutputStream) -> Bool {
- data.withUnsafeBytes { buffer in
- guard let base = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return false }
- var written = 0
- while written < data.count {
- let result = output.write(base.advanced(by: written), maxLength: data.count - written)
- if result <= 0 { return false }
- written += result
- }
- return true
- }
- }
-}
diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadService.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadService.swift
new file mode 100644
index 000000000..427bb8601
--- /dev/null
+++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadService.swift
@@ -0,0 +1,116 @@
+import Foundation
+import OSLog
+
+/// A file that native code holds and is about to upload.
+struct MediaUploadFile: Sendable, Equatable {
+ /// Where the file is on disk. The service never moves or deletes it.
+ let url: URL
+ let mimeType: String
+ let filename: String
+}
+
+/// Delivers native media uploads and deletes to WordPress.
+///
+/// A protocol so the transport that carries files to native code can be tested
+/// without a network behind it.
+protocol MediaUploading: Sendable {
+ /// Uploads `file` and returns WordPress's response verbatim, non-2xx included.
+ ///
+ /// Throws only when there is no response to relay — the processor or uploader
+ /// threw, the request failed at the transport layer, or the task was cancelled.
+ func upload(_ file: MediaUploadFile, fields: [MediaUploadField], query: String) async throws -> MediaUploadResponse
+
+ /// Deletes an attachment and returns WordPress's response verbatim.
+ func delete(attachmentId: String, query: String) async throws -> MediaUploadResponse
+}
+
+/// Runs the host's ``MediaProcessor``, then delivers the result through the host's
+/// ``MediaUploader`` or GutenbergKit's own ``InternalMediaClient``.
+///
+/// Independent of how the file reached native code: the editor's page hands files over
+/// through ``MediaUploadSchemeHandler``, and the block inserter imports them directly.
+/// Both end here.
+///
+/// Holds the processor and uploader **strongly**, so a processor that admitted a file
+/// processes it and an uploader that was handed one delivers it, even if the editor
+/// drops its references mid-upload. The editor owns the service through the scheme
+/// handler, which it releases in ``EditorViewController/stopMediaHandling()``.
+struct MediaUploadService: MediaUploading {
+ let processor: (any MediaProcessor)?
+ let uploader: (any MediaUploader)?
+ let internalClient: InternalMediaClient?
+
+ func upload(_ file: MediaUploadFile, fields: [MediaUploadField], query: String) async throws -> MediaUploadResponse {
+ // Ask the processor — from metadata alone — whether it will touch a file like
+ // this. A declined file skips `processFile`: handing it one anyway would break
+ // the contract the gate documents. With an uploader set the file is still
+ // delivered, just unprocessed.
+ let processorWantsFile = processor?.handlesFile(ofType: file.mimeType, named: file.filename) ?? false
+
+ let processed: ProcessedProxyFile
+ if let processor, processorWantsFile {
+ processed = try await processor.processFile(at: file.url, mimeType: file.mimeType, filename: file.filename)
+ } else {
+ processed = .original
+ }
+
+ // `.processed` uses the processor's values verbatim, so a format change is
+ // reported to WordPress.
+ let delivered: MediaUploadFile
+ switch processed {
+ case .original:
+ delivered = file
+ case let .processed(url, mimeType, filename):
+ delivered = MediaUploadFile(url: url, mimeType: mimeType, filename: filename)
+ }
+
+ // A file the processor produced is ours to clean up — uploaded on success,
+ // abandoned on failure. Here rather than in the caller so the throw paths are
+ // covered too.
+ defer {
+ if delivered.url != file.url {
+ try? FileManager.default.removeItem(at: delivered.url)
+ }
+ }
+
+ // The editor was torn down, or the page gave up on this upload, while we
+ // processed. Don't start an upload whose response nobody will read — it would
+ // create an attachment neither GutenbergKit nor the host knows to clean up.
+ // Checked here rather than left to the HTTP client so it holds for a
+ // host-injected `URLSessionProtocol` that ignores cancellation.
+ try Task.checkCancellation()
+
+ // An uploader owns delivery on the host's stack and returns the finished
+ // attachment (or throws); GutenbergKit relays it as a success and runs no
+ // recovery behind it.
+ if let uploader {
+ let upload = MediaUpload(
+ fileURL: delivered.url,
+ mimeType: delivered.mimeType,
+ filename: delivered.filename,
+ fields: fields,
+ query: query
+ )
+ let attachment = try await uploader.upload(upload)
+ return MediaUploadResponse(statusCode: 201, body: attachment)
+ }
+
+ guard let internalClient else {
+ throw UploadError.noUploader
+ }
+ return try await internalClient.upload(
+ fileURL: delivered.url,
+ mimeType: delivered.mimeType,
+ filename: delivered.filename,
+ fields: fields,
+ query: query
+ )
+ }
+
+ func delete(attachmentId: String, query: String) async throws -> MediaUploadResponse {
+ guard let internalClient else {
+ throw UploadError.noUploader
+ }
+ return try await internalClient.deleteMedia(attachmentId: attachmentId, query: query)
+ }
+}
diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadSessionStore.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadSessionStore.swift
new file mode 100644
index 000000000..8ca609982
--- /dev/null
+++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadSessionStore.swift
@@ -0,0 +1,174 @@
+import Foundation
+import OSLog
+
+/// The uploads waiting for their `finish` request: files the editor's page is sending
+/// to native code in chunks.
+///
+/// Every chunk is written straight to a staging file, so a file's size is bounded by
+/// disk, not memory. Sessions are one-shot — ``take(_:)`` removes the session — so a
+/// page that sends `finish` twice cannot upload the same file twice.
+actor MediaUploadSessionStore {
+ enum Failure: Error, Equatable {
+ /// No session with that ID. It never existed, was already finished or
+ /// cancelled, or was swept after sitting idle.
+ case unknownSession
+ /// A chunk arrived out of order: `offset` is where it claimed to start,
+ /// `expected` is how many bytes the session already has.
+ case offsetMismatch(expected: Int, offset: Int)
+ /// The file is larger than the store accepts.
+ case tooLarge(limit: Int)
+ /// `finish` arrived before every byte the page announced.
+ case incomplete(expected: Int, received: Int)
+ }
+
+ /// A finished session, handed to the uploader.
+ struct Finished: Sendable {
+ /// The staging copy the store wrote, which the caller deletes once the upload
+ /// is over.
+ let file: MediaUploadFile
+
+ /// Deletes the staging copy.
+ func cleanUp() {
+ try? FileManager.default.removeItem(at: file.url.deletingLastPathComponent())
+ }
+ }
+
+ private struct Session {
+ let file: MediaUploadFile
+ let expectedSize: Int?
+ var received: Int
+ /// Open for writing while the page sends chunks.
+ let handle: FileHandle
+ var lastActivity: Date
+ }
+
+ /// The largest file a session accepts: WordPress's own ceiling is far lower on
+ /// almost every host, so this only bounds a runaway page.
+ static let defaultMaxFileSize = 4 * 1024 * 1024 * 1024
+
+ /// Where every store stages its files. Each store works in its own subdirectory.
+ static var stagingRoot: URL {
+ FileManager.default.temporaryDirectory.appending(component: "GutenbergKit-uploads", directoryHint: .isDirectory)
+ }
+
+ let directory: URL
+ let maxFileSize: Int
+ private var sessions: [String: Session] = [:]
+
+ init(directory: URL? = nil, maxFileSize: Int = MediaUploadSessionStore.defaultMaxFileSize) {
+ self.directory = directory ?? Self.stagingRoot.appending(component: UUID().uuidString, directoryHint: .isDirectory)
+ self.maxFileSize = maxFileSize
+ }
+
+ /// Starts receiving a file from the page and returns the session ID.
+ ///
+ /// - Parameter expectedSize: The file's size as the page reported it. When given,
+ /// `finish` refuses a session that received a different number of bytes.
+ func begin(filename: String, mimeType: String, expectedSize: Int?) throws -> String {
+ if let expectedSize, expectedSize > maxFileSize {
+ throw Failure.tooLarge(limit: maxFileSize)
+ }
+ let id = UUID().uuidString.lowercased()
+ let sessionDirectory = directory.appending(component: id, directoryHint: .isDirectory)
+ try FileManager.default.createDirectory(at: sessionDirectory, withIntermediateDirectories: true)
+ let url = sessionDirectory.appending(component: Self.sanitizeFilename(filename))
+ guard FileManager.default.createFile(atPath: url.path(percentEncoded: false), contents: nil) else {
+ throw CocoaError(.fileWriteUnknown)
+ }
+ let handle = try FileHandle(forWritingTo: url)
+ sessions[id] = Session(
+ file: MediaUploadFile(url: url, mimeType: mimeType, filename: filename),
+ expectedSize: expectedSize,
+ received: 0,
+ handle: handle,
+ lastActivity: .now
+ )
+ return id
+ }
+
+ /// Appends a chunk and returns how many bytes the session now has.
+ ///
+ /// `offset` must equal the bytes already received. Chunks are sent one at a time,
+ /// so any other value means one went missing or arrived twice, and appending it
+ /// would silently corrupt the file.
+ func append(_ data: Data, to id: String, at offset: Int) throws -> Int {
+ guard var session = sessions[id] else { throw Failure.unknownSession }
+ guard offset == session.received else {
+ throw Failure.offsetMismatch(expected: session.received, offset: offset)
+ }
+ let total = session.received + data.count
+ guard total <= maxFileSize, total <= (session.expectedSize ?? maxFileSize) else {
+ discard(id)
+ throw Failure.tooLarge(limit: session.expectedSize.map { min($0, maxFileSize) } ?? maxFileSize)
+ }
+ try session.handle.write(contentsOf: data)
+ session.received = total
+ session.lastActivity = .now
+ sessions[id] = session
+ return total
+ }
+
+ /// Ends a session and returns its file for upload.
+ func take(_ id: String) throws -> Finished {
+ guard let session = sessions.removeValue(forKey: id) else { throw Failure.unknownSession }
+ try? session.handle.close()
+ let finished = Finished(file: session.file)
+ if let expected = session.expectedSize, expected != session.received {
+ finished.cleanUp()
+ throw Failure.incomplete(expected: expected, received: session.received)
+ }
+ return finished
+ }
+
+ /// Abandons a session, deleting what it received. Unknown IDs are ignored.
+ func discard(_ id: String) {
+ guard let session = sessions.removeValue(forKey: id) else { return }
+ try? session.handle.close()
+ Finished(file: session.file).cleanUp()
+ }
+
+ /// Abandons every session that has been idle for longer than `interval`.
+ func sweep(idleFor interval: TimeInterval) {
+ let cutoff = Date.now.addingTimeInterval(-interval)
+ for (id, session) in sessions where session.lastActivity < cutoff {
+ Logger.mediaUpload.info("Discarding upload session \(id), idle since \(session.lastActivity)")
+ discard(id)
+ }
+ }
+
+ /// Abandons every session, for when the editor stops handling media.
+ func removeAll() {
+ for id in Array(sessions.keys) {
+ discard(id)
+ }
+ try? FileManager.default.removeItem(at: directory)
+ }
+
+ var sessionCount: Int { sessions.count }
+
+ /// Deletes staging directories left behind by a store that never cleaned up — the
+ /// app was killed mid-upload. Anything younger than `age` is kept, so this can't
+ /// race another editor's upload in flight.
+ static func removeAbandonedStaging(olderThan age: TimeInterval = 3600) {
+ let cutoff = Date.now.addingTimeInterval(-age)
+ guard let entries = try? FileManager.default.contentsOfDirectory(
+ at: stagingRoot,
+ includingPropertiesForKeys: [.contentModificationDateKey]
+ ) else { return }
+ for entry in entries {
+ let modified = (try? entry.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate
+ if let modified, modified < cutoff {
+ try? FileManager.default.removeItem(at: entry)
+ }
+ }
+ }
+
+ /// The last path component of `name`, so a crafted filename can't escape the
+ /// session's directory.
+ static func sanitizeFilename(_ name: String) -> String {
+ let safe = (name as NSString).lastPathComponent
+ .replacingOccurrences(of: "/", with: "")
+ .replacingOccurrences(of: "\\", with: "")
+ return safe.isEmpty || safe == "." || safe == ".." ? "upload" : safe
+ }
+}
diff --git a/ios/Sources/GutenbergKit/Sources/Media/NativeFileInput.swift b/ios/Sources/GutenbergKit/Sources/Media/NativeFileInput.swift
new file mode 100644
index 000000000..325c09177
--- /dev/null
+++ b/ios/Sources/GutenbergKit/Sources/Media/NativeFileInput.swift
@@ -0,0 +1,70 @@
+import Foundation
+import WebKit
+
+/// Hands files native code holds to the editor's page as `File` objects.
+///
+/// The page clicks a file input (`requestNativeFiles` in `src/utils/native-files.js`),
+/// and this object answers the open panel WebKit would otherwise show with the files on
+/// offer. The page gets what the system picker gives it: `File`s that WebKit reads from
+/// disk as they are sliced, so a large video costs the page no memory, and a block that
+/// uploads on its own gets the real bytes.
+///
+/// WebKit asks its UI delegate for the panel from iOS 18.4. A delegate that implements
+/// the method answers every file input, so this object is the web view's UI delegate
+/// only while it has files on offer — any other input keeps the system picker.
+@MainActor
+final class NativeFileInput: NSObject, WKUIDelegate {
+ /// Whether WebKit asks the UI delegate for a file input's files on this OS.
+ nonisolated static var isSupported: Bool {
+ if #available(iOS 18.4, macOS 10.12, visionOS 2.4, *) {
+ return true
+ }
+ return false
+ }
+
+ private var offered: [URL]?
+
+ /// Whether files are on offer and no file input has claimed them yet.
+ var hasOffer: Bool { offered != nil }
+
+ /// Runs `operation` with `files` on offer to the first file input `webView`'s page
+ /// clicks, and returns its result.
+ ///
+ /// The offer ends when `operation` returns, whether or not the page took it. With
+ /// nothing to offer, or an offer already open, `operation` runs without one and the
+ /// page fetches the files itself.
+ func offer(
+ _ files: [URL],
+ to webView: WKWebView,
+ while operation: () async throws -> T
+ ) async rethrows -> T {
+ guard Self.isSupported, !files.isEmpty, offered == nil else {
+ return try await operation()
+ }
+ let previousDelegate = webView.uiDelegate
+ offered = files
+ webView.uiDelegate = self
+ defer {
+ offered = nil
+ webView.uiDelegate = previousDelegate
+ }
+ return try await operation()
+ }
+
+ /// The files on offer, which the first file input to ask gets. A second one gets
+ /// `nil`, which WebKit treats as the panel being cancelled.
+ func takeOffer() -> [URL]? {
+ defer { offered = nil }
+ return offered
+ }
+
+ @available(iOS 18.4, macOS 10.12, visionOS 2.4, *)
+ func webView(
+ _ webView: WKWebView,
+ runOpenPanelWith parameters: WKOpenPanelParameters,
+ initiatedByFrame frame: WKFrameInfo,
+ completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void
+ ) {
+ completionHandler(takeOffer())
+ }
+}
diff --git a/ios/Sources/GutenbergKit/Sources/Media/RestRelay.swift b/ios/Sources/GutenbergKit/Sources/Media/RestRelay.swift
new file mode 100644
index 000000000..edc5a43f6
--- /dev/null
+++ b/ios/Sources/GutenbergKit/Sources/Media/RestRelay.swift
@@ -0,0 +1,423 @@
+import Foundation
+import OSLog
+
+/// Relays the editor's REST API requests through the native networking stack.
+///
+/// ## Why this exists
+///
+/// The editor web view is a `file://` page. Its REST API requests normally
+/// bypass CORS thanks to the `allowUniversalAccessFromFileURLs` preference,
+/// but iOS Lockdown Mode stops honoring that exemption while still making the
+/// page send `Origin: file://`. WordPress sanitizes that value through a
+/// URL-protocol allowlist that doesn't include `file`, so it responds with an
+/// empty `Access-Control-Allow-Origin` and WebKit rejects every response.
+///
+/// The relay sidesteps the problem: the page fetches a URL scheme its own web
+/// view serves (``RestRelaySchemeHandler``), and this forwards the request to
+/// the site's REST API with the configured authorization header, responding
+/// with CORS headers we control. Every request takes this path, Lockdown Mode
+/// or not, so there is one path to keep working.
+///
+/// ## Security
+///
+/// - Only the editor's own web view can load the scheme, so there is no token
+/// to check.
+/// - The caller supplies a **path**, not a URL: everything after `/proxy/` is
+/// resolved natively against the configured site API root, so the relay
+/// cannot be pointed at another host by construction rather than by string
+/// matching. The resolved URL is re-checked against the root, and redirects
+/// away from it are refused.
+/// - Which route *within* the site is reached is the caller's to choose. The
+/// query is forwarded as-is, and WordPress registers `rest_route` as a public
+/// query variable that `WP::parse_request()` prefers over the route the path
+/// names, so a caller-supplied one wins. There is no boundary here to
+/// defend: every route reachable that way is one the editor may request
+/// through the relay directly.
+/// - The upstream `Authorization` header is injected natively from the editor
+/// configuration; any client-supplied value is discarded.
+struct RestRelay: Sendable {
+
+ /// The route the relay answers. Everything after it is the upstream path,
+ /// relative to the site API root — `/proxy/wp/v2/posts?…` relays to
+ /// `wp/v2/posts?…`.
+ static let route = "/proxy"
+
+ /// The site's API root, slash-terminated. Upstream paths are appended to
+ /// it, and every resulting URL — including redirect targets — must still
+ /// start with it.
+ ///
+ /// Held as a string rather than a `URL` because the root is not always
+ /// directory-shaped: a site on plain permalinks has
+ /// `https://example.com/?rest_route=/`, where relative URL resolution would
+ /// discard the query.
+ private let apiRoot: String
+
+ /// The authorization header injected into upstream requests.
+ private let authHeader: String
+
+ /// The session upstream requests are sent on.
+ private let session: URLSession
+
+ /// The session every relay shares.
+ ///
+ /// A `URLSession` holds its resources until it is invalidated, and a relay
+ /// is built on every editor load, so one session each would accumulate for
+ /// the life of the process. A relayed request is cancelled through its own
+ /// task rather than by tearing the session down.
+ private static let sharedSession: URLSession = {
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.timeoutIntervalForRequest = 120
+ configuration.httpCookieStorage = nil
+ return URLSession(configuration: configuration)
+ }()
+
+ /// - Parameter session: The session to send upstream requests on. Defaults
+ /// to the shared one; tests substitute a stubbed session to exercise
+ /// ``handle(_:)`` without a site.
+ init(configuration: EditorConfiguration, session: URLSession? = nil) {
+ self.apiRoot = Self.normalizedRoot(configuration.siteApiRoot.absoluteString)
+ self.authHeader = configuration.authHeader
+ self.session = session ?? Self.sharedSession
+ }
+
+ /// The configured root, slash-terminated, with the route value of a
+ /// plain-permalink root decoded.
+ ///
+ /// WordPress advertises that root through `add_query_arg`, which
+ /// percent-encodes the value: `index.php?rest_route=%2F`. The separators
+ /// are decoded before the slash is added so it lands inside the route
+ /// value. Appended after `%2F`, it would make a root no path can extend:
+ /// WordPress reads `rest_route=%2F/wp/v2/posts` as the route
+ /// `//wp/v2/posts` and answers `rest_no_route`. `createRelayFetch`
+ /// normalizes the same way, so both sides agree on what the root is.
+ private static func normalizedRoot(_ configured: String) -> String {
+ var root = configured
+ if let query = root.firstIndex(of: "?") {
+ let decoded = root[query...].replacingOccurrences(of: "%2f", with: "/", options: .caseInsensitive)
+ root = String(root[.. SchemeResponse {
+ guard let upstreamURL = request.url.flatMap(upstreamURL(for:)) else {
+ Logger.restRelay.error("Refusing to relay a request outside the site API root")
+ return Self.errorResponse(
+ status: 403,
+ code: "relay_forbidden_path",
+ message: "The requested path is outside the site API root."
+ )
+ }
+
+ var upstreamRequest = URLRequest(url: upstreamURL)
+ upstreamRequest.httpMethod = request.httpMethod
+ for (name, value) in request.allHTTPHeaderFields ?? [:] where !Self.requestHeadersToStrip.contains(name.lowercased()) {
+ upstreamRequest.setValue(value, forHTTPHeaderField: name)
+ }
+ if !authHeader.isEmpty {
+ upstreamRequest.setValue(authHeader, forHTTPHeaderField: "Authorization")
+ }
+ upstreamRequest.httpBody = request.httpBody
+
+ do {
+ // The redirect guard is a per-task delegate: `URLSession` follows
+ // 3xx responses on its own, which would carry the site credential
+ // to whatever host the `Location` header names and relay that
+ // response back. See ``RedirectGuard``.
+ let redirectGuard = RedirectGuard(allowedPrefix: apiRoot)
+ let (body, response) = try await session.data(for: upstreamRequest, delegate: redirectGuard)
+
+ // A refused redirect leaves `URLSession` holding the 3xx itself.
+ // Relaying that would undo the refusal: the response carries the
+ // `Location` the guard just declined, and `fetch` follows redirects
+ // by default, so the web view would chase it to the very host the
+ // guard exists to keep the request away from.
+ if let refused = redirectGuard.refusedTarget {
+ Logger.restRelay.error("Refused a relay redirect outside the site API root")
+ return Self.errorResponse(
+ status: 502,
+ code: "relay_redirect_refused",
+ message: "The site redirected this request to \(refused), which is outside its configured REST API root. The editor did not follow it."
+ )
+ }
+ guard let response = response as? HTTPURLResponse else {
+ return Self.errorResponse(status: 502, code: "relay_upstream_failed", message: "The site did not answer over HTTP.")
+ }
+
+ return SchemeResponse(
+ status: response.statusCode,
+ headers: Self.merged(response.allHeaderFields),
+ body: body
+ )
+ } catch {
+ Logger.restRelay.error("Upstream request failed: \(error.localizedDescription)")
+ return Self.errorResponse(status: 502, code: "relay_upstream_failed", message: error.localizedDescription)
+ }
+ }
+
+ // MARK: - Upstream URL
+
+ /// Builds the upstream URL for a request to the relay scheme, or `nil` if
+ /// the result would address anything outside the site API root.
+ ///
+ /// Everything after the ``route`` prefix is treated as a path relative to
+ /// the API root and appended to it. Appending rather than resolving is what
+ /// `createRootURLMiddleware` does on the JavaScript side, and it is the only
+ /// approach that works for both root shapes WordPress produces: pretty
+ /// permalinks give `https://example.com/wp-json/`, plain permalinks give
+ /// `https://example.com/?rest_route=/`, where the path has to merge into an
+ /// existing query string.
+ ///
+ /// Dot segments — literal or percent-encoded — are refused rather than
+ /// normalized. A REST path never contains one, `URLSession` resolves them
+ /// before sending, and a normalized `..` is the one thing that could walk
+ /// out of the API root and reach the rest of the site with the credential
+ /// attached.
+ func upstreamURL(for relayURL: URL) -> URL? {
+ guard let components = URLComponents(url: relayURL, resolvingAgainstBaseURL: false) else { return nil }
+ let path = components.percentEncodedPath
+ guard path == Self.route || path.hasPrefix("\(Self.route)/") else { return nil }
+
+ // Strip the route and any leading slashes, so the remainder appends to
+ // the API root rather than resolving against the site root.
+ let relativePath = path.dropFirst(Self.route.count).drop(while: { $0 == "/" })
+ guard !Self.containsDotSegment(relativePath) else { return nil }
+
+ var suffix = String(relativePath) + (components.percentEncodedQuery.map { "?\($0)" } ?? "")
+ // A root that already carries a query (plain permalinks) continues it
+ // rather than starting a second one — mirroring `createRootURLMiddleware`.
+ if apiRoot.contains("?"), let separator = suffix.firstIndex(of: "?") {
+ suffix.replaceSubrange(separator...separator, with: "&")
+ }
+
+ guard let url = URL(string: apiRoot + suffix),
+ url.absoluteString.hasPrefix(apiRoot) else {
+ return nil
+ }
+ return url
+ }
+
+ /// Whether `path` contains a `.` or `..` segment, including the
+ /// percent-encoded spellings a server may decode before resolving it.
+ ///
+ /// The separators are decoded alongside the dots. A server that decodes
+ /// `%2f` before normalizing — nginx normalizes the request URI ahead of
+ /// location matching — reads `%2e%2e%2fwp-admin` as `../wp-admin`, which
+ /// splitting on literal slashes alone would pass through. `%5c` is decoded
+ /// too because Windows-hosted servers treat a backslash as a separator.
+ private static func containsDotSegment(_ path: some StringProtocol) -> Bool {
+ let decoded = path.lowercased()
+ .replacingOccurrences(of: "%2e", with: ".")
+ .replacingOccurrences(of: "%2f", with: "/")
+ .replacingOccurrences(of: "%5c", with: "/")
+ .replacingOccurrences(of: "\\", with: "/")
+ guard decoded.contains(".") else { return false }
+ return decoded.split(separator: "/", omittingEmptySubsequences: false).contains {
+ $0 == "." || $0 == ".."
+ }
+ }
+
+ /// Refuses redirects that leave the site API root.
+ ///
+ /// `URLSession` follows 3xx responses automatically, so without this the
+ /// containment check would only ever apply to the first hop: a site that
+ /// redirected `/wp-json/wp/v2/posts` elsewhere would have the request —
+ /// carrying the site credential — followed to that host, and its response
+ /// relayed back to the editor. Refusing hands the 3xx itself back instead.
+ ///
+ /// The comparison is a prefix match on the whole URL, read through the
+ /// host spellings `relayUpstreamPath` tolerates — `www.` versus bare, the
+ /// loopback names — and an `http`→`https` upgrade. So another path on the
+ /// same site (`/wp-login.php`), another port, another host, and a scheme
+ /// downgrade are refused: the site credential follows the request only to
+ /// the API it was configured for. The cost is that a legitimate
+ /// permalink-structure redirect is refused too, which the response says
+ /// specifically enough to diagnose.
+ ///
+ /// The tolerances are the layer above's so that the two agree. A `Link`
+ /// target on the `www.` alias is relayed by `createRelayFetch`, and a site
+ /// whose canonical redirect names that alias — or whose `siteurl` is `http`
+ /// behind a TLS-terminating proxy — would otherwise have every relayed
+ /// request refused here. Containment holds: the same site under another
+ /// of its own names, and a strictly stronger scheme.
+ ///
+ /// A `307` or `308` it follows has `URLSession` resend the body, which it can:
+ /// a scheme request's body is `Data`, held in memory.
+ ///
+ /// `@unchecked Sendable`: the prefixes are `let`s set at init; the refusal is
+ /// recorded under a lock.
+ final class RedirectGuard: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
+ /// The API root, in the form ``normalized(_:)`` gives a target.
+ private let allowedPrefix: String
+
+ /// `allowedPrefix` under `https`, when the configured root is `http`.
+ private let upgradedPrefix: String?
+
+ private let lock = NSLock()
+ private var _refusedTarget: String?
+
+ /// The redirect target that was refused, or `nil` if none was.
+ var refusedTarget: String? {
+ lock.withLock { _refusedTarget }
+ }
+
+ init(allowedPrefix: String) {
+ let root = Self.normalized(allowedPrefix) ?? allowedPrefix
+ self.allowedPrefix = root
+ let insecureScheme = "http://"
+ self.upgradedPrefix = root.hasPrefix(insecureScheme)
+ ? "https://" + root.dropFirst(insecureScheme.count)
+ : nil
+ }
+
+ func urlSession(
+ _ session: URLSession,
+ task: URLSessionTask,
+ willPerformHTTPRedirection response: HTTPURLResponse,
+ newRequest request: URLRequest,
+ completionHandler: @escaping (URLRequest?) -> Void
+ ) {
+ guard let url = request.url, contains(url.absoluteString) else {
+ lock.withLock { _refusedTarget = request.url?.absoluteString ?? "an unreadable URL" }
+ completionHandler(nil)
+ return
+ }
+ completionHandler(request)
+ }
+
+ /// Whether `target` is inside the API root, allowing only the host
+ /// spelling and a scheme upgrade to differ.
+ private func contains(_ target: String) -> Bool {
+ guard let target = Self.normalized(target) else { return false }
+ if target.hasPrefix(allowedPrefix) {
+ return true
+ }
+ guard let upgradedPrefix else { return false }
+ return target.hasPrefix(upgradedPrefix)
+ }
+
+ /// `url` with its host in the form its aliases share and a default
+ /// port dropped, so that a prefix comparison reads through the
+ /// spellings the layer above tolerates. `nil` for a URL without a host.
+ private static func normalized(_ url: String) -> String? {
+ guard var components = URLComponents(string: url), let host = components.host else {
+ return nil
+ }
+ components.host = canonicalHost(host)
+ if let port = components.port, port == defaultPort(for: components.scheme) {
+ components.port = nil
+ }
+ return components.string
+ }
+
+ private static func defaultPort(for scheme: String?) -> Int? {
+ switch scheme?.lowercased() {
+ case "http": return 80
+ case "https": return 443
+ default: return nil
+ }
+ }
+
+ /// A host reduced to the form its aliases share: every loopback
+ /// spelling collapses to one, and a `www.` prefix is dropped.
+ ///
+ /// Mirrors `canonicalHost` in `fetch-relay.js`, and the two must stay
+ /// the same: a spelling the web view relays and this refuses fails
+ /// every request on a site whose canonical redirect uses it.
+ private static func canonicalHost(_ host: String) -> String {
+ let lowercased = host.lowercased()
+ if ["localhost", "127.0.0.1", "::1", "[::1]"].contains(lowercased) {
+ return "localhost"
+ }
+ return lowercased.hasPrefix("www.") ? String(lowercased.dropFirst(4)) : lowercased
+ }
+ }
+
+ // MARK: - Headers
+
+ /// Headers every relayed response carries.
+ ///
+ /// Under Lockdown Mode WebKit enforces CORS on a scheme response too, and
+ /// the page only sees the response headers the expose list names. A name
+ /// missing from it does not fail loudly: `headers.get()` returns `null`, so
+ /// the feature behind it reads as absent rather than broken. `canUser`
+ /// reads `Allow`, and a block's own uploader reads whatever its endpoint
+ /// answers with. Hence the leading `*`, which is valid because relayed
+ /// requests are sent `credentials: 'omit'`.
+ ///
+ /// The four names stay listed behind the wildcard because `*` is ignored
+ /// for a *credentialed* request. They are the names whose absence is known
+ /// to break a feature: `Allow` for capabilities, `Link` for
+ /// `fetchAllMiddleware`'s pagination, `X-WP-Total`/`X-WP-TotalPages` for
+ /// list counts.
+ static let corsHeaders: [String: String] = [
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "*",
+ "Access-Control-Allow-Headers": "*",
+ "Access-Control-Expose-Headers": "*, Allow, Link, X-WP-Total, X-WP-TotalPages",
+ ]
+
+ /// Request headers that stay behind.
+ ///
+ /// `host`, `connection` and `content-length` belong to the hop;
+ /// `accept-encoding` is `URLSession`'s to set; `origin`, `referer` and
+ /// `sec-fetch-*` describe the web view's fetch context and would leak the
+ /// local page to the site (and WordPress rejects a `file://` origin — the
+ /// exact problem the relay exists to solve); `cookie` is the page's, and
+ /// the relay carries the site credential itself; `authorization` is the
+ /// caller's, replaced by the natively held site credential.
+ private static let requestHeadersToStrip: Set = [
+ "host", "connection", "content-length", "accept-encoding",
+ "origin", "referer",
+ "sec-fetch-site", "sec-fetch-mode", "sec-fetch-dest", "sec-fetch-user",
+ "cookie", "authorization",
+ ]
+
+ /// Upstream response headers dropped from relayed responses.
+ ///
+ /// The CORS strip is load-bearing: an upstream
+ /// `Access-Control-Allow-Origin` (WordPress sends an empty one for origins
+ /// it rejects) would otherwise replace the relay's own.
+ ///
+ /// `Content-Encoding` and `Content-Length` must go because `URLSession`
+ /// already decompressed the body: advertising the upstream encoding would
+ /// make WebKit decode the plain bytes a second time, and the upstream
+ /// length is the compressed one.
+ ///
+ /// `Set-Cookie` is the site's, scoped to the site. The relay carries the
+ /// site credential natively and never needs it.
+ private static let responseHeadersToStrip: Set = [
+ "access-control-allow-origin", "access-control-allow-credentials",
+ "access-control-allow-headers", "access-control-allow-methods",
+ "access-control-expose-headers", "access-control-max-age", "vary",
+ "content-encoding", "content-length",
+ "set-cookie", "set-cookie2",
+ ]
+
+ /// The upstream response's headers as the editor should see them: the
+ /// upstream's CORS and transport-encoding headers dropped (see
+ /// `responseHeadersToStrip`), and the relay's own CORS headers added.
+ private static func merged(_ upstream: [AnyHashable: Any]) -> [String: String] {
+ var headers: [String: String] = [:]
+ for (name, value) in upstream {
+ guard let name = name as? String, let value = value as? String,
+ !responseHeadersToStrip.contains(name.lowercased()) else { continue }
+ headers[name] = value
+ }
+ return headers.merging(corsHeaders) { _, relay in relay }
+ }
+
+ /// A WordPress-REST-style error rather than plain text, so the editor
+ /// decodes a relay failure the same way it decodes WordPress's own.
+ static func errorResponse(status: Int, code: String, message: String) -> SchemeResponse {
+ var response = SchemeResponse.error(status, code: code, message: message)
+ response.headers.merge(corsHeaders) { _, relay in relay }
+ return response
+ }
+}
diff --git a/ios/Sources/GutenbergKit/Sources/Media/RestRelaySchemeHandler.swift b/ios/Sources/GutenbergKit/Sources/Media/RestRelaySchemeHandler.swift
new file mode 100644
index 000000000..08f52122c
--- /dev/null
+++ b/ios/Sources/GutenbergKit/Sources/Media/RestRelaySchemeHandler.swift
@@ -0,0 +1,87 @@
+import Foundation
+import WebKit
+
+/// Serves ``RestRelay`` to the editor's page over a `gbk-rest:` URL scheme.
+///
+/// The page's `fetch` wrapper (`src/utils/fetch-relay.js`) rewrites a request for
+/// the site's REST API to `gbk-rest://relay/proxy/`, and this hands it to
+/// the relay and answers with what the site said.
+@MainActor
+final class RestRelaySchemeHandler: NSObject, WKURLSchemeHandler {
+ nonisolated static let scheme = "gbk-rest"
+
+ /// The URL the page appends an upstream path to, slash-terminated.
+ nonisolated static let baseURL = "\(scheme)://relay\(RestRelay.route)/"
+
+ private let relay: RestRelay
+
+ /// Every request WebKit has started and not stopped, held strongly: a finished
+ /// task's identity can be reused by the next one, so tracking tasks by identifier
+ /// alone answers the wrong request.
+ private var active: [ObjectIdentifier: ActiveRequest] = [:]
+
+ private struct ActiveRequest {
+ let task: any WKURLSchemeTask
+ var work: Task?
+ }
+
+ init(relay: RestRelay) {
+ self.relay = relay
+ super.init()
+ }
+
+ // MARK: - WKURLSchemeHandler
+
+ func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
+ start(urlSchemeTask)
+ }
+
+ func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {
+ stop(urlSchemeTask)
+ }
+
+ /// `webView(_:start:)` without the web view, for tests.
+ func start(_ task: any WKURLSchemeTask) {
+ let key = ObjectIdentifier(task)
+ let request = task.request
+ let relay = relay
+ active[key] = ActiveRequest(task: task, work: nil)
+ active[key]?.work = Task { [weak self] in
+ let response = await relay.handle(request)
+ self?.reply(to: key, with: response)
+ }
+ }
+
+ /// `webView(_:stop:)` without the web view, for tests.
+ ///
+ /// WebKit stops a task when the page aborts its `fetch` or goes away. Cancelling
+ /// the work cancels the request to the site, and dropping the task keeps `reply`
+ /// from answering it: answering a stopped task raises an Objective-C exception.
+ func stop(_ task: any WKURLSchemeTask) {
+ active.removeValue(forKey: ObjectIdentifier(task))?.work?.cancel()
+ }
+
+ var activeRequestCount: Int { active.count }
+
+ private func reply(to key: ObjectIdentifier, with response: SchemeResponse) {
+ guard let request = active.removeValue(forKey: key) else {
+ return // Stopped: WebKit raises if a stopped task is answered.
+ }
+ let task = request.task
+ guard let url = task.request.url,
+ let httpResponse = HTTPURLResponse(
+ url: url,
+ statusCode: response.status,
+ httpVersion: "HTTP/1.1",
+ headerFields: response.headers
+ ) else {
+ task.didFailWithError(URLError(.badServerResponse))
+ return
+ }
+ task.didReceive(httpResponse)
+ if !response.body.isEmpty {
+ task.didReceive(response.body)
+ }
+ task.didFinish()
+ }
+}
diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift
index b92e06937..3af451c5f 100644
--- a/ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift
+++ b/ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift
@@ -14,7 +14,7 @@ import SwiftSoup
///
/// Assets are accessed via URL lookup - the bundle maintains a mapping from
/// original remote URLs to local file paths.
-public struct EditorAssetBundle: Sendable, Equatable, Hashable {
+public struct EditorAssetBundle: Sendable {
/// The EditorRepresentation has the exact same format as `RemoteEditorAssetManifest.RawManifest` – what we're passing to Gutenberg
/// looks exactly like what it'd get if it called `/wpcom/v2/editor-assets` directly.
@@ -35,6 +35,8 @@ public struct EditorAssetBundle: Sendable, Equatable, Hashable {
struct RawAssetBundle: Codable {
let manifest: LocalEditorAssetManifest
let downloadDate: Date
+ /// Absent from a bundle stored before this was recorded.
+ var lastCheckedDate: Date?
}
/// The bundle's unique identifier, derived from its manifest checksum.
@@ -50,9 +52,24 @@ public struct EditorAssetBundle: Sendable, Equatable, Hashable {
/// The date this bundle was created by downloading the manifest contents.
///
- /// Used to determine which bundle is most recent when multiple bundles exist.
+ /// Used to determine which bundle is most recent when multiple bundles exist, until the bundle
+ /// has a ``lastCheckedDate``.
let downloadDate: Date
+ /// The date the site's manifest was last found to match this bundle, if that's been recorded.
+ ///
+ /// It says how recently the bundle was confirmed, not what the bundle is, so two copies of a
+ /// bundle that differ only in this are equal.
+ let lastCheckedDate: Date?
+
+ /// When the site's manifest is last known to have matched this bundle: when it was last
+ /// checked, or else when it was downloaded.
+ ///
+ /// Used to determine which bundle is the site's latest, and how old it is for the cache policy.
+ var lastMatchedDate: Date {
+ lastCheckedDate ?? downloadDate
+ }
+
/// The number of assets stored in this bundle.
public var assetCount: Int {
manifest.assetUrls.count
@@ -63,12 +80,19 @@ public struct EditorAssetBundle: Sendable, Equatable, Hashable {
init(raw: RawAssetBundle, bundleRoot: URL) {
self.manifest = raw.manifest
self.downloadDate = raw.downloadDate
+ self.lastCheckedDate = raw.lastCheckedDate
self.bundleRoot = bundleRoot
}
- init(manifest: LocalEditorAssetManifest, downloadDate: Date = Date(), bundleRoot: URL) throws {
+ init(
+ manifest: LocalEditorAssetManifest,
+ downloadDate: Date = Date(),
+ lastCheckedDate: Date? = nil,
+ bundleRoot: URL
+ ) throws {
self.manifest = manifest
self.downloadDate = downloadDate
+ self.lastCheckedDate = lastCheckedDate
self.bundleRoot = bundleRoot
}
@@ -186,7 +210,8 @@ public struct EditorAssetBundle: Sendable, Equatable, Hashable {
func dataRepresentation() throws -> Data {
try JSONEncoder().encode(RawAssetBundle(
manifest: self.manifest,
- downloadDate: self.downloadDate
+ downloadDate: self.downloadDate,
+ lastCheckedDate: self.lastCheckedDate
))
}
@@ -233,3 +258,15 @@ public struct EditorAssetBundle: Sendable, Equatable, Hashable {
bundleRoot: URL.temporaryDirectory
)
}
+
+extension EditorAssetBundle: Equatable, Hashable {
+ public static func == (lhs: EditorAssetBundle, rhs: EditorAssetBundle) -> Bool {
+ lhs.manifest == rhs.manifest && lhs.downloadDate == rhs.downloadDate && lhs.bundleRoot == rhs.bundleRoot
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(manifest)
+ hasher.combine(downloadDate)
+ hasher.combine(bundleRoot)
+ }
+}
diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorCachePolicy.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorCachePolicy.swift
index 292498cd3..d46d7b78f 100644
--- a/ios/Sources/GutenbergKit/Sources/Model/EditorCachePolicy.swift
+++ b/ios/Sources/GutenbergKit/Sources/Model/EditorCachePolicy.swift
@@ -4,7 +4,8 @@ import Foundation
///
/// `EditorCachePolicy` provides three caching strategies that control when cached
/// HTTP responses are considered valid. This is used by `EditorURLCache` to decide
-/// whether to return a cached response or require a fresh network request.
+/// whether to return a cached response or require a fresh network request, and by
+/// `EditorAssetLibrary` to decide when to check a site's asset manifest again.
///
/// ## Usage
///
diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift
index 69661facd..c03ca74d0 100644
--- a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift
+++ b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift
@@ -9,7 +9,8 @@ import Foundation
public enum NetworkFallbackMode: Sendable, Hashable {
/// Network failures are fatal and propagate as errors (current default behavior).
case disabled
- /// Automatically fall back to the bundled editor when network requests fail.
+ /// Automatically fall back when network requests fail: to the dependencies already on disk,
+ /// even ones the cache policy considers too old, or else to the bundled editor.
case automatic
}
diff --git a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift
index a06c793b2..56a22de43 100644
--- a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift
+++ b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift
@@ -80,11 +80,20 @@ public struct GBKitGlobal: Sendable, Codable {
/// Whether to log network requests in the JavaScript console.
let enableNetworkLogging: Bool
- /// Port the local HTTP server is listening on for native media uploads.
- let nativeUploadPort: Int?
-
- /// Per-session auth token for requests to the local upload server.
- let nativeUploadToken: String?
+ /// The URL scheme the editor's page sends media uploads to native code over, or
+ /// `nil` when uploads go straight from the page to WordPress.
+ let nativeUploadScheme: String?
+
+ /// Where the page sends the site's REST API requests for native code to relay, or
+ /// `nil` when they go straight from the page to the site.
+ let restRelay: RestRelayLocation?
+
+ /// Where the native REST relay is.
+ struct RestRelayLocation: Codable, Sendable {
+ /// The URL a path below the site's REST API root is appended to,
+ /// slash-terminated.
+ let baseURL: String
+ }
let editorSettings: JSON?
@@ -98,13 +107,15 @@ public struct GBKitGlobal: Sendable, Codable {
/// - Parameters:
/// - configuration: The editor configuration.
/// - dependencies: The pre-fetched editor dependencies (unused but reserved for future use).
- /// - nativeUploadPort: Port of the local upload server, or nil if not running.
- /// - nativeUploadToken: Auth token for the local upload server, or nil if not running.
+ /// - nativeUploadScheme: The scheme native media uploads use, or nil when the
+ /// editor has no native media handling.
+ /// - restRelayBaseURL: The URL the page relays the site's REST API requests
+ /// through, or nil when it sends them itself.
public init(
configuration: EditorConfiguration,
dependencies: EditorDependencies,
- nativeUploadPort: Int? = nil,
- nativeUploadToken: String? = nil
+ nativeUploadScheme: String? = nil,
+ restRelayBaseURL: String? = nil
) throws {
self.siteURL = configuration.isOfflineModeEnabled ? nil : configuration.siteURL
self.siteApiRoot = configuration.isOfflineModeEnabled ? nil : configuration.siteApiRoot
@@ -127,8 +138,8 @@ public struct GBKitGlobal: Sendable, Codable {
)
self.logLevel = configuration.logLevel.rawValue
self.enableNetworkLogging = configuration.enableNetworkLogging
- self.nativeUploadPort = nativeUploadPort
- self.nativeUploadToken = nativeUploadToken
+ self.nativeUploadScheme = nativeUploadScheme
+ self.restRelay = restRelayBaseURL.map(RestRelayLocation.init(baseURL:))
self.editorSettings = dependencies.editorSettings.jsonValue
self.preloadData = try dependencies.preloadList?.build()
self.editorAssets = Self.buildEditorAssets(from: dependencies.assetBundle)
diff --git a/ios/Sources/GutenbergKit/Sources/Model/JSON.swift b/ios/Sources/GutenbergKit/Sources/Model/JSON.swift
index 6435fb466..450a7307d 100644
--- a/ios/Sources/GutenbergKit/Sources/Model/JSON.swift
+++ b/ios/Sources/GutenbergKit/Sources/Model/JSON.swift
@@ -23,9 +23,24 @@ public enum JSON: Sendable, Equatable, Hashable, CustomStringConvertible {
self = try JSONDecoder().decode(JSON.self, from: data)
}
+ /// The value as JSON, laid out for reading.
public var description: String {
- let data = try! JSONSerialization.data(withJSONObject: self, options: [.prettyPrinted, .withoutEscapingSlashes])
- return String(data: data, encoding: .utf8)!
+ // Not `JSONSerialization`, which takes Foundation objects: handed this enum, it raises an
+ // exception that Swift can't catch.
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
+ // JSON has no way to write these numbers, and a description shouldn't fail over one.
+ encoder.nonConformingFloatEncodingStrategy = .convertToString(
+ positiveInfinity: "Infinity",
+ negativeInfinity: "-Infinity",
+ nan: "NaN"
+ )
+
+ guard let data = try? encoder.encode(self) else {
+ return ""
+ }
+
+ return String(decoding: data, as: UTF8.self)
}
}
diff --git a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift
index db4927c61..43ae68b5d 100644
--- a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift
+++ b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift
@@ -66,7 +66,10 @@ public struct RESTAPIRepository: Sendable {
// MARK: Post
@discardableResult
public func fetchPost(id: Int) async throws -> EditorURLResponse {
- let request = URLRequest(method: .GET, url: self.buildPostUrl(id: id))
+ var request = URLRequest(method: .GET, url: self.buildPostUrl(id: id))
+ // The post is never cached, and for the same reason never joins a request in flight:
+ // one started by an editor since closed can predate an edit made in between.
+ request.cachePolicy = .reloadIgnoringLocalCacheData
let response = try await self.httpClient.perform(request)
return EditorURLResponse(response)
}
diff --git a/ios/Sources/GutenbergKit/Sources/Services/EditorDependencyLoader.swift b/ios/Sources/GutenbergKit/Sources/Services/EditorDependencyLoader.swift
new file mode 100644
index 000000000..b73e4483e
--- /dev/null
+++ b/ios/Sources/GutenbergKit/Sources/Services/EditorDependencyLoader.swift
@@ -0,0 +1,50 @@
+import Foundation
+
+/// Fetches an editor's dependencies without holding the editor.
+///
+/// The editor owns its loader, never the reverse: the loader reaches back only through
+/// ``delegate``, which is weak and whose requirements are all synchronous. So a released
+/// editor is freed at once rather than when the fetch ends — it never awaits the fetch,
+/// and nothing the loader calls on it can suspend. Keep it that way by reading
+/// `delegate` where it is used: a copy held across the `await` would retain the editor
+/// for the whole fetch.
+///
+/// The fetch starts on init and is never cancelled, so it keeps the loader alive until it
+/// finishes. That is harmless — a loader owns no view, web view, or listener — and a
+/// fetch that outlives its editor still warms the cache for the next one.
+@MainActor
+final class EditorDependencyLoader {
+ weak let delegate: (any EditorDependencyLoaderDelegate)?
+
+ init(service: EditorService, delegate: any EditorDependencyLoaderDelegate) {
+ self.delegate = delegate
+ fetch(from: service)
+ }
+
+ /// Kept out of `init`, where the strong `delegate` parameter would shadow the weak
+ /// property — so here the task can reach the delegate only through ``delegate``.
+ private func fetch(from service: EditorService) {
+ Task(priority: .userInitiated) {
+ do {
+ let dependencies = try await service.prepare { @MainActor progress in
+ self.delegate?.dependencyLoader(self, didUpdate: progress)
+ }
+ delegate?.dependencyLoader(self, didLoad: dependencies)
+ } catch {
+ delegate?.dependencyLoader(self, didFailWith: error)
+ }
+ }
+ }
+}
+
+/// Receives an ``EditorDependencyLoader``'s results on the main actor.
+///
+/// Every requirement is synchronous, so no call can suspend while holding the delegate.
+/// An `async` requirement could, and would keep the editor alive until the call resumed —
+/// for the whole fetch, if the call awaited it.
+@MainActor
+protocol EditorDependencyLoaderDelegate: AnyObject {
+ func dependencyLoader(_ loader: EditorDependencyLoader, didUpdate progress: EditorProgress)
+ func dependencyLoader(_ loader: EditorDependencyLoader, didLoad dependencies: EditorDependencies)
+ func dependencyLoader(_ loader: EditorDependencyLoader, didFailWith error: any Error)
+}
diff --git a/ios/Sources/GutenbergKit/Sources/Services/EditorService.swift b/ios/Sources/GutenbergKit/Sources/Services/EditorService.swift
index d870c197a..342223f0b 100644
--- a/ios/Sources/GutenbergKit/Sources/Services/EditorService.swift
+++ b/ios/Sources/GutenbergKit/Sources/Services/EditorService.swift
@@ -21,9 +21,17 @@ public actor EditorService {
private let restRepository: RESTAPIRepository
private let assetLibrary: EditorAssetLibrary
+ /// What `prepare()` falls back to when the site can't be reached and the network fallback is
+ /// automatic: this service under the `.always` policy, which uses whatever is on disk however
+ /// old it is. `nil` when the service wouldn't use it.
+ private let diskFallback: EditorService?
+
private var progress: EditorProgress?
private var progressCallback: EditorProgressCallback?
+ /// How much of the asset bundle's weight has been counted toward `progress`.
+ private var assetBundleProgress = 0
+
enum DependencyWeights: CaseIterable {
case editorSettings
case assetBundle
@@ -55,7 +63,9 @@ public actor EditorService {
/// - cachePolicy: The policy that determines when cached responses are considered valid.
/// Use `.ignore` to always fetch fresh data, `.maxAge(_:)` to expire entries after
/// a time interval, or `.always` (the default) to use cached data regardless of age.
- /// This policy applies to both API response caching and asset manifest caching.
+ /// This policy applies to both API responses and plugin and theme assets. For assets, it
+ /// decides when to check the site's asset manifest again; an unchanged manifest keeps the
+ /// bundle already on disk rather than downloading its assets again.
public init(
configuration: EditorConfiguration,
httpClient: (any EditorHTTPClientProtocol)? = nil,
@@ -114,6 +124,19 @@ public actor EditorService {
cachePolicy: cachePolicy,
storageRoot: storageRoot ?? Paths.storageRoot(for: configuration)
)
+
+ switch (configuration.networkFallbackMode, cachePolicy) {
+ case (.automatic, .maxAge), (.automatic, .ignore):
+ self.diskFallback = EditorService(
+ configuration: configuration,
+ httpClient: httpClient,
+ cachePolicy: .always,
+ storageRoot: storageRoot,
+ cacheRoot: cacheRoot
+ )
+ case (.automatic, .always), (.disabled, _):
+ self.diskFallback = nil
+ }
}
/// Returns the number of asset bundles currently stored on disk.
@@ -126,6 +149,10 @@ public actor EditorService {
/// This method fetches editor settings, plugin assets, and preload data concurrently,
/// caching results for future use. If offline mode is enabled, returns empty dependencies.
///
+ /// If the site can't be reached and the configuration's network fallback is automatic, this
+ /// returns the dependencies on disk instead of throwing — even ones too old for the cache
+ /// policy, which can't be checked without the site — and empty dependencies if any are missing.
+ ///
/// - Parameter progress: A callback invoked with progress updates during loading.
/// - Returns: The complete set of dependencies needed to initialize the editor.
/// - Throws: An error if any required resource fails to download.
@@ -142,6 +169,7 @@ public actor EditorService {
self.progress = EditorProgress(completed: 1, total: 100)
self.progressCallback = progress
+ self.assetBundleProgress = 0
defer {
self.progressCallback = nil
self.progress = nil
@@ -152,6 +180,13 @@ public actor EditorService {
return try await fetchDependencies()
} catch {
guard isNetworkError(error) else { throw error }
+
+ // Nothing on disk can be checked against a site that can't be reached, and what's
+ // there beats loading with nothing — however old it is.
+ if let diskFallback {
+ return try await diskFallback.prepare()
+ }
+
return EditorDependencies(
editorSettings: .undefined,
assetBundle: .empty,
@@ -166,6 +201,7 @@ public actor EditorService {
/// Clear unused on-disk resources associated with this service's configuration.
///
/// Calling this method will preserve the most recent cache entries, ensuring that the editor still loads quickly without continuing to use unnecessary disk space.
+ /// It also preserves any asset bundle the app has been handed since it launched, which an open editor or dependencies the host is holding may still be using.
/// Use this method to regularly clean up unused editor assets.
public func cleanup() async throws {
try await self.assetLibrary.cleanup()
@@ -179,14 +215,33 @@ public actor EditorService {
try self.restRepository.purge()
}
- private func incrementProgress(for weight: DependencyWeights, fraction: Double = 1.0) async {
- precondition(
- self.progress != nil,
- "Progress has not been initialized. This is a bug in the EditorService. Please file an issue."
- )
+ private func incrementProgress(for weight: DependencyWeights) async {
+ await self.incrementProgress(by: Int(weight.rawValue))
+ }
+
+ /// Counts an asset bundle download's progress toward the total. The download reports how far
+ /// along it is each time, not how much further than the last time, so only what's new is added.
+ private func incrementProgress(forAssetBundleDownload download: EditorProgress) async {
+ let assetBundleProgress = Int(DependencyWeights.assetBundle.rawValue * download.fractionCompleted)
+ let increase = assetBundleProgress - self.assetBundleProgress
+ guard increase > 0 else { return }
+
+ self.assetBundleProgress = assetBundleProgress
+ await self.incrementProgress(by: increase)
+ }
+
+ private func incrementProgress(by amount: Int) async {
+ // Progress can arrive after the `prepare()` it belongs to has returned and cleared it. A
+ // bundle build shared with another service may already be calling in when this service
+ // gives up on it, and an overlapping `prepare()` on this service is cleared by whichever
+ // finishes first. There is nothing left to report to, so drop it.
+ guard let current = self.progress else { return }
+
+ // Progress starts at 1, and a post adds its weight to the others', so the weights can add
+ // up to more than the total.
let progress = EditorProgress(
- completed: self.progress!.completed + Int(weight.rawValue * fraction),
- total: self.progress!.total)
+ completed: min(current.completed + amount, current.total),
+ total: current.total)
self.progress = progress
await self.progressCallback?(progress)
}
@@ -196,10 +251,12 @@ public actor EditorService {
async let assetBundle = try self.prepareAssetBundle()
async let preloadList = try preparePreloadList()
- // Automatically clean up old asset bundles
- try await onceEvery(.seconds(86_400)) {
- try await self.cleanup()
- }
+ // Automatically clean up old asset bundles, once a day for each site
+ try await onceEvery(
+ .seconds(86_400),
+ { try await self.cleanup() },
+ handle: "asset-bundle-cleanup-\(self.configuration.siteId)"
+ )
return try await EditorDependencies(
editorSettings: settings,
@@ -232,14 +289,18 @@ public actor EditorService {
}
private func prepareAssetBundle() async throws -> EditorAssetBundle {
- if let latestAssetBundle = try await self.assetLibrary.readAssetBundles().first {
+ if let latestAssetBundle = try await self.assetLibrary.readLatestAssetBundle() {
await self.incrementProgress(for: .assetBundle)
return latestAssetBundle
}
- return try await self.assetLibrary.downloadAssetBundle { progress in
- await self.incrementProgress(for: .assetBundle, fraction: progress.fractionCompleted)
+ let assetBundle = try await self.assetLibrary.downloadAssetBundle { progress in
+ await self.incrementProgress(forAssetBundleDownload: progress)
}
+
+ // A bundle with nothing to download reports no progress
+ await self.incrementProgress(forAssetBundleDownload: EditorProgress(completed: 1, total: 1))
+ return assetBundle
}
private func preparePreloadList() async throws -> EditorPreloadList {
diff --git a/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift b/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift
index 0c720891c..5d8292855 100644
--- a/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift
+++ b/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift
@@ -9,14 +9,29 @@ public actor EditorAssetLibrary {
private let storageRoot: URL
private let cachePolicy: EditorCachePolicy
+ /// Bundle builds in flight, keyed by the directory each writes. Every service builds its own
+ /// library, so this is shared across all of them.
+ static let inFlightBuilds = InFlightTasks()
+
+ /// Guards every change to which bundles are on disk and which of them is a site's latest —
+ /// marking the latest, `cleanup()` and `purge()` — along with `handedOut`. Every service
+ /// builds its own library, so an actor's isolation doesn't order these between libraries.
+ private static let storageLock = NSLock()
+
+ /// The directory of every bundle this process has handed to a caller. An editor, or
+ /// dependencies a host is holding, may still be reading one, so `cleanup()` leaves them be.
+ nonisolated(unsafe) private static var handedOut: Set = []
+
/// Creates a new `EditorAssetLibrary` instance.
///
/// - Parameters:
/// - configuration: The editor configuration containing site-specific settings.
/// - httpClient: The HTTP client used to fetch remote assets.
- /// - cachePolicy: The policy that determines when cached asset manifests are considered valid.
- /// Use `.ignore` to always fetch fresh manifests, `.maxAge(_:)` to expire entries after
- /// a time interval, or `.always` (the default) to use cached manifests regardless of age.
+ /// - cachePolicy: The policy that determines how long ``readLatestAssetBundle()`` goes on
+ /// returning the latest bundle on disk before the site's manifest has to be checked again.
+ /// Use `.ignore` to check it every time, `.maxAge(_:)` to check it once the last check is
+ /// older than a time interval, or `.always` (the default) to check it only when there is
+ /// no bundle on disk.
/// - storageRoot: The root directory where asset bundles will be stored on disk.
public init(
configuration: EditorConfiguration,
@@ -34,28 +49,37 @@ public actor EditorAssetLibrary {
/// Retrieve the manifest for a given site configuration.
///
- /// Applications should periodically check for a new editor manifest. This can be very expensive, so this method defaults to returning an existing one on-disk.
+ /// Parsing a manifest is expensive, so when a bundle built from the same manifest is already on disk, this
+ /// method returns that bundle's copy rather than parsing it again.
///
func fetchManifest() async throws -> LocalEditorAssetManifest {
guard configuration.shouldUsePlugins else { return .empty }
- let data = try await httpClient.perform(
- URLRequest(method: .GET, url: self.editorAssetsUrl(for: self.configuration))
- ).0
+ var request = URLRequest(method: .GET, url: self.editorAssetsUrl(for: self.configuration))
+ switch self.cachePolicy {
+ case .always:
+ // Only asked when there's no bundle to use, so an answer already on its way will do
+ break
+ case .maxAge, .ignore:
+ // A check is to find out what the site serves now, which neither a stored response
+ // nor a request already in flight can say.
+ request.cachePolicy = .reloadIgnoringLocalCacheData
+ }
+ let data = try await httpClient.perform(request).0
let remoteManifest = try RemoteEditorAssetManifest(data: data)
- guard
- let existingManifest = self.existingBundle(forManifestChecksum: remoteManifest.checksum),
- self.cachePolicy.allowsResponseWith(date: existingManifest.downloadDate)
- else {
- return try LocalEditorAssetManifest(remoteManifest: remoteManifest)
+ // The checksum covers the whole response, so a bundle with the same one was built from
+ // this exact manifest.
+ if let existingBundle = self.existingBundle(forManifestChecksum: remoteManifest.checksum) {
+ return existingBundle.manifest
}
- return existingManifest.manifest
+ return try LocalEditorAssetManifest(remoteManifest: remoteManifest)
}
// MARK: - Bundle Handling
- /// The downloaded asset bundles for a given `EditorConfiguration`. Ordered newest to oldest.
+ /// The downloaded asset bundles for a given `EditorConfiguration`, ordered by when the site's manifest last
+ /// matched each: the latest first.
///
public func readAssetBundles() throws -> [EditorAssetBundle] {
try FileManager.default.createDirectory(at: self.storageRoot, withIntermediateDirectories: true)
@@ -63,24 +87,60 @@ public actor EditorAssetLibrary {
.contentsOfDirectory(at: self.storageRoot, includingPropertiesForKeys: [.isDirectoryKey])
.filter { $0.hasDirectoryPath } // Only include directories
.filter { $0.pathExtension != "download" } // Don't include bundles that are being downloaded
- .map { $0.appending(path: "manifest.json") }
+ // Not the listed URL itself: a listing resolves symlinks in the path (`/var` to `/private/var`), which
+ // would make a bundle read here unequal to the same bundle built or looked up by checksum.
+ .map { self.bundleManifestPath(for: $0.lastPathComponent) }
.compactMap { try? EditorAssetBundle(url: $0) } // Skip invalid/incomplete bundles
- .sorted { $0.downloadDate > $1.downloadDate }
+ // Not by when each was downloaded: a site can go back to a manifest it had before, whose bundle
+ // was downloaded earlier than the one it replaced.
+ .sorted { $0.lastMatchedDate > $1.lastMatchedDate }
+ }
+
+ /// The latest bundle on disk, if the cache policy still trusts it.
+ ///
+ /// Returns `nil` when there is no bundle, or when the site's manifest was last checked too long ago for the
+ /// policy. Either way, call ``downloadAssetBundle(progress:)`` next to check it.
+ public func readLatestAssetBundle() throws -> EditorAssetBundle? {
+ guard
+ let latestBundle = try self.readAssetBundles().first,
+ self.cachePolicy.allowsResponseWith(date: latestBundle.lastMatchedDate)
+ else {
+ return nil
+ }
+
+ return Self.storageLock.withLock {
+ guard self.hasBundle(forManifestChecksum: latestBundle.id) else { return nil }
+ Self.handedOut.insert(self.bundleRoot(for: latestBundle).standardizedFileURL)
+ return latestBundle
+ }
}
/// Fetches the latest manifest from the server and downloads all of its resources, caching them on-disk.
///
+ /// If a bundle built from the same manifest is already on disk, it's returned instead, without downloading its
+ /// assets again: they're versioned by URL, so an unchanged manifest means unchanged assets. Only an asset that
+ /// an earlier build failed to download is tried again. Either way the bundle becomes the site's latest, and
+ /// its age for the cache policy starts over. To download every asset again regardless, ``purge()`` the
+ /// library first.
+ ///
/// - Parameter progress: An optional callback that receives progress updates as assets are downloaded.
/// - Returns: The downloaded `EditorAssetBundle` containing all cached assets.
/// - Throws: An error if the manifest cannot be fetched or assets fail to download.
public func downloadAssetBundle(
- cachePolicy: EditorCachePolicy = .always,
progress: EditorProgressCallback? = nil
) async throws -> EditorAssetBundle {
let manifest = try await self.fetchManifest()
return try await self.buildBundle(for: manifest, progress: progress)
}
+ @available(*, deprecated, message: "`cachePolicy` has no effect: this always checks the site's manifest. Drop the argument.")
+ public func downloadAssetBundle(
+ cachePolicy: EditorCachePolicy,
+ progress: EditorProgressCallback? = nil
+ ) async throws -> EditorAssetBundle {
+ try await self.downloadAssetBundle(progress: progress)
+ }
+
/// Checks whether a complete bundle with the given manifest checksum exists on disk.
///
/// A bundle is considered complete only if both `manifest.json` and `editor-representation.json` exist.
@@ -106,7 +166,9 @@ public actor EditorAssetLibrary {
/// Downloads all of the assets for a given manifest and assembles them into a bundle.
///
/// Assets are downloaded concurrently and stored in a temporary directory. Once all downloads
- /// complete successfully, the bundle is atomically moved to its final location.
+ /// complete successfully, the bundle is atomically moved to its final location. If a complete
+ /// bundle for the manifest is already there, it's returned instead, once any asset it's missing
+ /// has been tried again. Either way, the bundle is marked as the site's latest.
func buildBundle(
for manifest: LocalEditorAssetManifest,
progress: EditorProgressCallback? = nil
@@ -118,8 +180,78 @@ public actor EditorAssetLibrary {
return .empty
}
- var complete = 0
+ // Every build of one manifest writes the same directory, whichever library runs it:
+ // join a build in flight rather than race a second one into it.
+ let destination = self.bundleRoot(for: manifest.checksum).standardizedFileURL
+ return try await Self.inFlightBuilds.value(for: destination, progress: progress) { report in
+ // Checked here rather than before joining, so that a build finishing in between is
+ // reused, and so that reusing a bundle doesn't race a build in flight replacing it.
+ if let existingBundle = await self.existingBundle(forManifestChecksum: manifest.checksum) {
+ try await self.downloadMissingAssets(of: existingBundle, reportingTo: report)
+
+ // A `cleanup()` or `purge()` can delete the bundle after it's found here, in which
+ // case there is nothing to reuse after all.
+ if let latestBundle = await self.markLatest(existingBundle) {
+ return latestBundle
+ }
+ }
+ let bundle = try await self.build(manifest, reportingTo: report)
+ return await self.markLatest(bundle) ?? bundle
+ }
+ }
+
+ /// Downloads whichever of `bundle`'s assets aren't on disk. A build publishes its bundle without any asset
+ /// that fails to download, and an unchanged manifest would otherwise never give that asset another try.
+ private func downloadMissingAssets(
+ of bundle: EditorAssetBundle,
+ reportingTo progress: EditorProgressCallback
+ ) async throws {
+ let missingAssets = self.downloadableAssets(in: bundle.manifest)
+ .filter { !FileManager.default.fileExists(at: self.assetPath(for: $0, in: bundle)) }
+
+ guard !missingAssets.isEmpty else {
+ await progress(EditorProgress(completed: 1, total: 1))
+ return
+ }
+
+ try await self.downloadAssets(missingAssets, into: bundle, reportingTo: progress)
+ }
+
+ /// Records that the site's manifest matches `bundle` now, and that the bundle has been handed out. That makes
+ /// it the site's latest bundle, and starts its age for the cache policy over. Returns `nil` if the bundle is
+ /// no longer on disk.
+ private func markLatest(_ bundle: EditorAssetBundle) -> EditorAssetBundle? {
+ Self.storageLock.withLock {
+ guard self.hasBundle(forManifestChecksum: bundle.id) else {
+ return nil
+ }
+
+ Self.handedOut.insert(self.bundleRoot(for: bundle).standardizedFileURL)
+
+ do {
+ let latestBundle = try EditorAssetBundle(
+ manifest: bundle.manifest,
+ downloadDate: bundle.downloadDate,
+ lastCheckedDate: Date(),
+ bundleRoot: bundle.bundleRoot
+ )
+ // Written straight to the file, which is known to be there: `writeManifest()` would
+ // create the bundle's directory if it weren't.
+ try latestBundle.dataRepresentation().write(to: self.bundleManifestPath(for: bundle), options: .atomic)
+ return latestBundle
+ } catch {
+ // The bundle is still complete and correct; it'll just be checked again sooner.
+ log(.warn, "Failed to mark asset bundle \(bundle.id) as the latest: \(error.localizedDescription)")
+ return bundle
+ }
+ }
+ }
+
+ private func build(
+ _ manifest: LocalEditorAssetManifest,
+ reportingTo progress: EditorProgressCallback
+ ) async throws -> EditorAssetBundle {
let tempDirectory = URL.temporaryDirectory.appending(path: UUID().uuidString)
let bundle = try EditorAssetBundle(
@@ -130,10 +262,26 @@ public actor EditorAssetLibrary {
let editorRepresentation = try manifest.buildEditorRepresentation(for: self.configuration)
try bundle.writeManifest(editorRepresentation: editorRepresentation)
- await withTaskGroup { group in
- let links = (manifest.scripts + manifest.styles).filter { self.isSupportedAsset($0) }
+ try await self.downloadAssets(self.downloadableAssets(in: manifest), into: bundle, reportingTo: progress)
- for asset in links {
+ return try bundle.copy(to: self.bundleRoot(for: bundle))
+ }
+
+ /// The assets in `manifest` that belong in its bundle.
+ private func downloadableAssets(in manifest: LocalEditorAssetManifest) -> [URL] {
+ (manifest.scripts + manifest.styles).filter { self.isSupportedAsset($0) }
+ }
+
+ /// Downloads `assets` into `bundle` concurrently, tolerating any that fail.
+ private func downloadAssets(
+ _ assets: [URL],
+ into bundle: EditorAssetBundle,
+ reportingTo progress: EditorProgressCallback
+ ) async throws {
+ var complete = 0
+
+ await withTaskGroup { group in
+ for asset in assets {
group.addTask {
do {
try await self.fetchAsset(url: asset, into: bundle)
@@ -147,11 +295,16 @@ public actor EditorAssetLibrary {
for await _ in group {
complete += 1
- await progress?(EditorProgress(completed: complete, total: links.count))
+ await progress(EditorProgress(completed: complete, total: assets.count))
}
}
- return try bundle.copy(to: self.bundleRoot(for: bundle))
+ // The group swallows every per-asset failure, cancellation included, so a
+ // cancelled download still arrives here with assets missing. Nothing downstream
+ // checks for them — `readAssetBundles()` reads only the manifest — so publishing
+ // the bundle, or recording it as the latest, would serve the gap on every later
+ // launch.
+ try Task.checkCancellation()
}
/// Downloads a single asset and copies it into the temporary bundle directory.
@@ -162,7 +315,7 @@ public actor EditorAssetLibrary {
try await httpClient.download(URLRequest(method: .GET, url: url)).0
}
- let destinationPath = bundle.bundleRoot.appending(path: url.path(percentEncoded: false))
+ let destinationPath = self.assetPath(for: url, in: bundle)
let destinationParent = destinationPath.deletingLastPathComponent()
// Ensure the destination directory exists
@@ -173,6 +326,11 @@ public actor EditorAssetLibrary {
return destinationPath
}
+ /// Where the asset at `url` is stored in `bundle`.
+ private func assetPath(for url: URL, in bundle: EditorAssetBundle) -> URL {
+ bundle.bundleRoot.appending(path: url.path(percentEncoded: false))
+ }
+
/// Checks if the given `url` is eligible to be downloaded into the local bundle
///
/// Only HTTP/HTTPS URLs with `.js`, `.css`, or `.js.map` extensions are supported.
@@ -199,25 +357,31 @@ public actor EditorAssetLibrary {
} else if let namespace = configuration.siteApiNamespace.first {
// Insert namespace: /wpcom/v2/editor-assets -> /wpcom/v2/sites/123/editor-assets
baseUrl = configuration.siteApiRoot
- .appending(path: "/wpcom/v2/\(namespace)editor-assets")
+ .appending(rawPath: "/wpcom/v2/\(namespace)editor-assets")
} else {
baseUrl = configuration.siteApiRoot
- .appending(path: "/wpcom/v2/editor-assets")
+ .appending(rawPath: "/wpcom/v2/editor-assets")
}
return baseUrl.appending(queryItems: [URLQueryItem(name: "exclude", value: "core,gutenberg")])
}
/// Cleans up outdated library entries for this site.
///
- /// This method removes all asset bundles except the most recent one, freeing disk space
- /// while ensuring the editor can still load quickly with cached assets.
+ /// This method removes all asset bundles except the latest one, freeing disk space
+ /// while ensuring the editor can still load quickly with cached assets. It also keeps any
+ /// bundle the app has been handed since it launched: an open editor, or dependencies the
+ /// host is still holding, may be reading it. Those are removed by a cleanup after the next launch.
///
/// - Throws: An error if the list of bundles cannot be read, or any bundle cannot be removed.
public func cleanup() throws {
- let bundles = try self.readAssetBundles().dropFirst()
+ try Self.storageLock.withLock {
+ for bundle in try self.readAssetBundles().dropFirst() {
+ let bundleRoot = self.bundleRoot(for: bundle)
- for bundle in bundles {
- try FileManager.default.removeItem(at: self.bundleRoot(for: bundle))
+ if !Self.handedOut.contains(bundleRoot.standardizedFileURL) {
+ try FileManager.default.removeItem(at: bundleRoot)
+ }
+ }
}
}
@@ -228,12 +392,14 @@ public actor EditorAssetLibrary {
///
/// - Throws: An error if the storage directory cannot be removed or recreated.
public func purge() throws {
- guard FileManager.default.directoryExists(at: self.storageRoot) else {
- return
- }
+ try Self.storageLock.withLock {
+ guard FileManager.default.directoryExists(at: self.storageRoot) else {
+ return
+ }
- try FileManager.default.removeItem(at: self.storageRoot)
- try FileManager.default.createDirectory(at: self.storageRoot, withIntermediateDirectories: true)
+ try FileManager.default.removeItem(at: self.storageRoot)
+ try FileManager.default.createDirectory(at: self.storageRoot, withIntermediateDirectories: true)
+ }
}
// MARK: - File Path Helpers
diff --git a/ios/Sources/GutenbergKit/Sources/Stores/EditorURLCache.swift b/ios/Sources/GutenbergKit/Sources/Stores/EditorURLCache.swift
index 40cfad115..fb2545758 100644
--- a/ios/Sources/GutenbergKit/Sources/Stores/EditorURLCache.swift
+++ b/ios/Sources/GutenbergKit/Sources/Stores/EditorURLCache.swift
@@ -8,11 +8,12 @@ import OSLog
///
/// Backed by `SQLiteKVCache`. The cache directory is built as
/// `//`, so two caches with different `siteId`s are
-/// guaranteed-distinct backing files. The "one instance per backing file"
-/// contract from `SQLiteKVCache` still applies for the same `(siteId,
-/// parentDirectory)` pair, but the typical call pattern (one cache per
-/// `EditorService`, one service per editor view) keeps that contract by
-/// construction.
+/// guaranteed-distinct backing files. Caches for the same `(siteId,
+/// parentDirectory)` pair share one store through
+/// `SQLiteKVCache.shared(handle:directory:diskCapacity:)`, which is what keeps
+/// that store's "one instance per backing file" contract: every
+/// `EditorService` builds its own cache, and a prefetch and an editor for the
+/// same site routinely run at once.
public struct EditorURLCache: Sendable {
/// About enough for 10 sites of cached responses.
private static let diskCapacity = Measurement(value: 100, unit: .mebibytes)
@@ -38,7 +39,9 @@ public struct EditorURLCache: Sendable {
parentDirectory: URL = Paths.defaultCacheRoot,
cachePolicy: EditorCachePolicy = .always
) {
- self.store = SQLiteKVCache(
+ // Shared: every service for a site builds its own cache, and two stores on one
+ // file break each other.
+ self.store = SQLiteKVCache.shared(
handle: "editorurlcache",
directory: parentDirectory.appending(path: siteId),
diskCapacity: Self.diskCapacity
@@ -159,6 +162,17 @@ public struct EditorURLCache: Sendable {
try self.store.clear()
}
+ /// Deletes every site's cache under `parentDirectory`, including any still in use.
+ ///
+ /// A cache still open when this is called fails every read and write from then on, so its
+ /// store is no longer shared: a cache created afterwards opens a new file.
+ static func deleteAll(in parentDirectory: URL = Paths.defaultCacheRoot) throws {
+ guard FileManager.default.directoryExists(at: parentDirectory) else { return }
+ // Whether or not the removal finishes: one that fails partway has still deleted files.
+ defer { SQLiteKVCache.forgetInstances(under: parentDirectory) }
+ try FileManager.default.removeItem(at: parentDirectory)
+ }
+
/// Combines the HTTP method and URL into a single string key. `SQLiteKVCache`
/// hashes the key with SHA-256 before binding to SQLite, so length, escaping,
/// and encoding aren't concerns here.
diff --git a/ios/Sources/GutenbergKit/Sources/Stores/SQLiteKVCache.swift b/ios/Sources/GutenbergKit/Sources/Stores/SQLiteKVCache.swift
index 706fd9060..9b723a8d3 100644
--- a/ios/Sources/GutenbergKit/Sources/Stores/SQLiteKVCache.swift
+++ b/ios/Sources/GutenbergKit/Sources/Stores/SQLiteKVCache.swift
@@ -42,8 +42,10 @@ import SQLite3
/// instances with different caps would clobber each other's triggers; in the
/// best case you get the wrong cap, in the worst case `SQLITE_BUSY` while the
/// recreations race. Each backing file must have exactly one owning
-/// `SQLiteKVCache` for the lifetime of the process. Not currently enforced at
-/// runtime — this is a usage contract.
+/// `SQLiteKVCache` at a time. Within a process, ``shared(handle:directory:diskCapacity:)``
+/// enforces that by handing every caller the live instance for its file; `init`
+/// doesn't, so use it directly only where nothing else can open the file. Across
+/// processes it remains a usage contract.
///
/// **Schema migrations.** A `schemaVersion` constant baked into the build is
/// compared against `PRAGMA user_version` on open; mismatches drop and recreate
@@ -131,6 +133,11 @@ final class SQLiteKVCache: @unchecked Sendable {
/// silently wipe users' caches a second time on the next upgrade).
private static let schemaVersion: Int32 = 1
+ /// How long a statement waits on another connection's lock before failing with
+ /// `SQLITE_BUSY`. The usual holder is the previous instance on the same file checkpointing
+ /// its WAL as it closes, which takes milliseconds; this only bounds a pathological one.
+ private static let busyTimeoutMilliseconds: Int32 = 5_000
+
private static let logger = Logger(subsystem: "GutenbergKit", category: "sqlite-kv-cache")
/// SQLite C API: signals that bound data should be copied. Reinvented here
@@ -203,6 +210,9 @@ final class SQLiteKVCache: @unchecked Sendable {
try Self.openAndConfigure(directory: self.directory, filename: self.filename, diskCapacity: self.diskCapacity)
}
dbResult = result
+ if case .failure = result {
+ Self.forget(self)
+ }
return try result.get()
}
@@ -248,6 +258,14 @@ final class SQLiteKVCache: @unchecked Sendable {
throw Error.databaseUnavailable
}
+ // Wait out another connection's lock rather than fail on it: `connection()` caches a
+ // failure for the life of the cache, so a lock held for milliseconds would break it for
+ // good. `shared(handle:directory:diskCapacity:)` makes that routine — it hands out a
+ // fresh instance as soon as the last one is released, while that one's `deinit` may
+ // still be checkpointing the WAL. Reopening in that window failed 200 times in 200
+ // without a timeout, and never with one.
+ sqlite3_busy_timeout(connection, Self.busyTimeoutMilliseconds)
+
// Pragmas + schema setup. Pragmas first because `journal_mode`
// changes must run with no active transaction. `journal_mode = WAL`
// switches from the default rollback-journal to a write-ahead log:
@@ -675,6 +693,77 @@ extension SQLiteKVCache.Error: CustomStringConvertible, LocalizedError {
extension SQLiteKVCache {
+ /// The live cache for `handle` in `directory`, created if there is none.
+ ///
+ /// Two instances on one file race their opens, and the loser caches its failure and
+ /// throws for the rest of its life. Measured with two `EditorURLCache`s making their
+ /// first read at the same moment: at least one ended up broken in 50 runs out of 50.
+ /// The busy timeout doesn't save it — the switch to WAL fails with `SQLITE_BUSY`
+ /// without waiting on it. Every caller that can share a file must come through here.
+ ///
+ /// Callers sharing a file must agree on `diskCapacity`: one that finds the file open
+ /// gets the live instance as it is, cap included.
+ static func shared(
+ handle: StaticString,
+ directory: URL = URL.cachesDirectory,
+ diskCapacity: Measurement
+ ) -> SQLiteKVCache {
+ let file = registryKey(directory: directory, filename: "\(handle)".lowercased())
+ let bytes = Int(diskCapacity.converted(to: .bytes).value)
+ return liveInstancesLock.withLock {
+ if let live = liveInstances[file]?.instance {
+ assert(
+ live.diskCapacity == bytes,
+ "'\(handle)' is already open with a different disk capacity; callers sharing a file must agree on it"
+ )
+ return live
+ }
+ let instance = SQLiteKVCache(handle: handle, directory: directory, diskCapacity: bytes)
+ liveInstances[file] = WeakInstance(instance: instance)
+ return instance
+ }
+ }
+
+ /// Stops `shared` handing out the instances for files under `directory`, so the next caller
+ /// for each opens it afresh. For a caller that has just deleted `directory`: an instance
+ /// still open on a deleted file fails every read and write with `SQLITE_IOERR`, and would
+ /// go on being shared for as long as anything held it.
+ static func forgetInstances(under directory: URL) {
+ let root = directory.standardizedFileURL.path(percentEncoded: false)
+ let prefix = root.hasSuffix("/") ? root : root + "/"
+ liveInstancesLock.withLock {
+ liveInstances = liveInstances.filter { !$0.key.hasPrefix(prefix) }
+ }
+ }
+
+ /// Stops `shared` handing out `instance`, whose open has failed. It keeps that failure for
+ /// life, so sharing it would fail every later caller for as long as anything held it. It
+ /// has closed its handle, so the fresh instance the next caller gets has the file to itself.
+ ///
+ /// Called with the instance's `openLock` held, which is why `shared` asks nothing of an
+ /// instance: an open can take as long as the busy timeout, and `shared` runs on the main
+ /// thread whenever an editor builds its service.
+ private static func forget(_ instance: SQLiteKVCache) {
+ let file = registryKey(directory: instance.directory, filename: instance.filename)
+ liveInstancesLock.withLock {
+ if liveInstances[file]?.instance === instance {
+ liveInstances[file] = nil
+ }
+ }
+ }
+
+ private static func registryKey(directory: URL, filename: String) -> String {
+ directory.standardizedFileURL.appending(component: filename).path(percentEncoded: false)
+ }
+
+ /// Weak, so a file no one is using is closed, and opened afresh by the next caller.
+ nonisolated(unsafe) private static var liveInstances: [String: WeakInstance] = [:]
+ private static let liveInstancesLock = NSLock()
+
+ private struct WeakInstance {
+ weak var instance: SQLiteKVCache?
+ }
+
/// Convenience initializer that accepts the cap as a
/// `Measurement` so callers can write
/// `Measurement(value: 100, unit: .mebibytes)` instead of an opaque
diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift
index 7d4554d36..0cc635106 100644
--- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift
+++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift
@@ -136,7 +136,7 @@ struct BlockInserterView: View {
BlockInserterSectionView(section: section, onBlockSelected: insertBlock)
.padding(.bottom, 6)
- if viewModel.searchText.isEmpty && section.category == "gbk-most-used" {
+ if canInsertDeviceMedia && viewModel.searchText.isEmpty && section.category == "gbk-most-used" {
inlinePhotosPicker
}
}
@@ -144,6 +144,15 @@ struct BlockInserterView: View {
.padding(.horizontal)
}
+ /// Whether the inserter offers the photo library and the camera.
+ ///
+ /// Media picked here reaches the page as a file, which needs ``NativeFileInput``.
+ /// Where it isn't supported the pickers are hidden, and media is added from a
+ /// block's own upload button, through WebKit's picker.
+ private var canInsertDeviceMedia: Bool {
+ NativeFileInput.isSupported
+ }
+
private var inlinePickerSpacing: CGFloat {
if #available(iOS 26, *) { -6.0 } else { 16.0 }
}
@@ -165,23 +174,25 @@ struct BlockInserterView: View {
customSearchField
}
- PhotosPicker(
- selection: $selectedMediaItems,
- preferredItemEncoding: .compatible
- ) {
- Image(systemName: "photo.on.rectangle.angled")
- }
- .onChange(of: selectedMediaItems) { _, selection in
- if !selection.isEmpty {
- insertMedia(selection)
+ if canInsertDeviceMedia {
+ PhotosPicker(
+ selection: $selectedMediaItems,
+ preferredItemEncoding: .compatible
+ ) {
+ Image(systemName: "photo.on.rectangle.angled")
+ }
+ .onChange(of: selectedMediaItems) { _, selection in
+ if !selection.isEmpty {
+ insertMedia(selection)
+ }
+ selectedMediaItems = []
}
- selectedMediaItems = []
- }
- Button {
- isShowingCamera = true
- } label: {
- Image(systemName: "camera")
+ Button {
+ isShowingCamera = true
+ } label: {
+ Image(systemName: "camera")
+ }
}
Button {
diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift
index 80ce1ad99..95ad22921 100644
--- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift
+++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift
@@ -118,10 +118,10 @@ class BlockInserterViewModel: ObservableObject {
mediaInfo = MediaInfo(url: fileURL.absoluteString, type: "image/jpeg")
case .video(let videoURL):
- let videoData = try Data(contentsOf: videoURL)
- let fileExtension = videoURL.pathExtension.isEmpty ? "mp4" : videoURL.pathExtension
- let fileURL = try await fileManager.writeData(videoData, withExtension: fileExtension)
- mediaInfo = MediaInfo(url: fileURL.absoluteString, type: "video/\(fileExtension)")
+ // Copied, not read: a clone on APFS, so a long recording never
+ // passes through memory. The type comes from the extension —
+ // `video/MOV` is not a MIME type WordPress accepts.
+ mediaInfo = try await fileManager.importFile(at: videoURL)
}
guard !Task.isCancelled else {
diff --git a/ios/Sources/GutenbergKitDebugServer/README.md b/ios/Sources/GutenbergKitDebugServer/README.md
deleted file mode 100644
index afaa31fb7..000000000
--- a/ios/Sources/GutenbergKitDebugServer/README.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# GutenbergKitDebugServer
-
-A command-line HTTP server for testing and debugging the `GutenbergKitHTTP` module. It logs incoming requests in detail and can optionally proxy them to an upstream URL.
-
-## Running
-
-```bash
-swift run GutenbergKitDebugServer # auto-assign a port
-swift run GutenbergKitDebugServer 8080 # listen on port 8080
-```
-
-## What it does
-
-For every incoming request, the server:
-
-1. **Logs** the method, target, headers, body size, and parse duration.
-2. **Inspects multipart bodies** — if the request has a `multipart/form-data` content type, each part's name, filename, content type, and size are printed. Small parts (≤ 200 bytes) have their text content printed inline.
-3. **Proxies** the request if an `X-URL-to-fetch` header is present. The header value is used as the upstream URL; the original method and headers (minus `Host` and `X-URL-to-fetch`) are forwarded via `URLSession`. The upstream response is returned to the client.
-4. **Echoes** a JSON summary if no proxy header is set.
-
-## Example output
-
-```
-GutenbergKitDebugServer listening on http://localhost:49312
-[2026-03-10T12:00:00Z] POST /wp/v2/media (0.42ms)
- Host: localhost:49312
- Content-Type: multipart/form-data; boundary=----FormBoundary
- Content-Length: 1234
- Body: 1234 bytes
- Multipart: 2 part(s)
- [0] name="title" (text/plain)
- 5 bytes
- Hello
- [1] name="file" filename="photo.jpg" (image/jpeg)
- 1100 bytes
-```
diff --git a/ios/Sources/GutenbergKitDebugServer/main.swift b/ios/Sources/GutenbergKitDebugServer/main.swift
deleted file mode 100644
index 9cb26d4fb..000000000
--- a/ios/Sources/GutenbergKitDebugServer/main.swift
+++ /dev/null
@@ -1,96 +0,0 @@
-import Foundation
-import GutenbergKitHTTP
-
-let port: UInt16? = CommandLine.arguments.dropFirst().first.flatMap(UInt16.init)
-
-let server = try await HTTPServer.start(name: "debug-server", port: port) { req in
- await logRequest(req)
-
- if let response = await fetchUrl(req) {
- return response
- }
-
- let request = req.parsed
-
- let json: [String: Any] = [
- "method": request.method,
- "target": request.target,
- "headers": request.headerCount,
- "status": "ok"
- ]
- let body = try! JSONSerialization.data(withJSONObject: json)
-
- return HTTPResponse(
- status: 200,
- headers: [("Content-Type", "application/json")],
- body: body
- )
-}
-
-print("GutenbergKitDebugServer listening on http://localhost:\(server.port)")
-print("Proxy-Authorization: Bearer \(server.token)")
-try await Task.sleep(for: .seconds(86400)) // Run the server for 24 hours
-
-func fetchUrl(_ req: HTTPServer.Request) async -> HTTPServer.Response? {
- guard let urlString = req.parsed.header("X-URL-to-fetch"), let url = URL(string: urlString) else {
- return nil
- }
-
- guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
- return HTTPResponse(status: 400, body: Data("Only http/https URLs are supported".utf8))
- }
-
- do {
- let filteredHeaders: Set = [
- "host", "x-url-to-fetch", "proxy-authorization"
- ]
-
- var request = URLRequest(url: url)
- request.httpMethod = req.parsed.method
- for (name, value) in req.parsed.allHeaders where !filteredHeaders.contains(name.lowercased()) {
- request.setValue(value, forHTTPHeaderField: name)
- }
-
- print(" Requesting \(url)")
- print(" Method: \(request.httpMethod)")
- print(" Headers: \(String(describing: request.allHTTPHeaderFields))")
-
- return try await HTTPResponse(URLSession.shared.data(for: request))
- } catch {
- print(" Request Failed: \(error.localizedDescription)")
- return HTTPResponse(status: 500, body: Data(error.localizedDescription.utf8))
- }
-}
-
-// MARK: - Logging
-
-func logRequest(_ req: HTTPServer.Request) async {
- let request = req.parsed
- let timestamp = ISO8601DateFormatter().string(from: Date())
- let ms = String(format: "%.2f", Double(req.parseDuration.components.attoseconds) / 1e15)
- print("[\(timestamp)] \(request.method) \(request.target) (\(ms)ms)")
-
- for (name, value) in request.allHeaders {
- print(" \(name): \(value)")
- }
-
- if let body = request.body {
- print(" Body: \(body.count) bytes")
-
- if let parts = try? request.multipartParts() {
- print(" Multipart: \(parts.count) part(s)")
- for (i, part) in parts.enumerated() {
- let filename = part.filename.map { " filename=\"\($0)\"" } ?? ""
- print(" [\(i)] name=\"\(part.name)\"\(filename) (\(part.contentType))")
- print(" \(part.body.count) bytes")
- if part.body.count <= 200, let text = try? await String(data: part.body.data, encoding: .utf8) {
- print(" \(text)")
- }
- }
- } else if body.count <= 500, let text = try? await String(data: body.data, encoding: .utf8) {
- print(" \(text)")
- }
- }
- print()
- fflush(stdout)
-}
diff --git a/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift b/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift
deleted file mode 100644
index 6ccfb1c48..000000000
--- a/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift
+++ /dev/null
@@ -1,53 +0,0 @@
-import Foundation
-
-/// CORS behavior for an ``HTTPServer``.
-public enum CORSPolicy: Sendable {
- /// No CORS headers are added (the default).
- case none
-
- /// Permissive CORS for a loopback-only server serving a WebView: allows any
- /// origin and the methods/headers this library's clients use. The server
- /// answers OPTIONS preflight requests itself and stamps these headers on
- /// every response — including ones it generates internally (timeouts, parse
- /// errors) that never reach the handler.
- case permissive
-
- /// Headers added to every response under this policy.
- var responseHeaders: [(String, String)] {
- switch self {
- case .none:
- []
- case .permissive:
- [
- // `*` is deliberate, not an oversight to tighten: the server is
- // loopback-only, and every non-OPTIONS request needs a
- // per-session bearer token that is never persisted, only
- // injected into the editor page. The token, not the origin,
- // gates access; echoing the origin isn't viable anyway, as the
- // editor loads from `file://` (Origin `null`).
- ("Access-Control-Allow-Origin", "*"),
- ("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"),
- ("Access-Control-Allow-Headers", "Authorization, Relay-Authorization, Content-Type"),
- // Only CORS-safelisted response headers are readable
- // cross-origin by default. The editor needs to read
- // `x-wp-upload-attachment-id` off a relayed media upload
- // response to retry `post-process` and clean up an orphaned
- // attachment, so it has to be exposed explicitly.
- ("Access-Control-Expose-Headers", "x-wp-upload-attachment-id"),
- ("Access-Control-Max-Age", "86400"),
- ]
- }
- }
-}
-
-extension HTTPResponse {
- /// Returns a copy with `newHeaders` appended, skipping any whose name
- /// (case-insensitive) is already present.
- func addingHeadersIfAbsent(_ newHeaders: [(String, String)]) -> HTTPResponse {
- guard !newHeaders.isEmpty else { return self }
- let existing = Set(headers.map { $0.0.lowercased() })
- let toAdd = newHeaders.filter { !existing.contains($0.0.lowercased()) }
- guard !toAdd.isEmpty else { return self }
- return HTTPResponse(status: status, statusText: statusText, headers: headers + toAdd, body: body)
- }
-}
diff --git a/ios/Sources/GutenbergKitHTTP/Extensions.swift b/ios/Sources/GutenbergKitHTTP/Extensions.swift
deleted file mode 100644
index 4a7ee8f66..000000000
--- a/ios/Sources/GutenbergKitHTTP/Extensions.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-import Foundation
-
-extension FileHandle {
-
- /// Opens a file for reading, passes the handle to `body`, and guarantees the handle
- /// is closed when `body` returns — whether normally or by throwing.
- ///
- /// ```swift
- /// let data = try FileHandle.withReadHandle(forUrl: fileURL) { handle in
- /// try handle.seek(toOffset: 100)
- /// return try handle.read(upToCount: 50) ?? Data()
- /// }
- /// ```
- ///
- /// - Parameters:
- /// - url: The file URL to open for reading.
- /// - body: A closure that receives the open `FileHandle`.
- /// - Returns: The value returned by `body`.
- /// - Throws: Rethrows any error from opening the file or from `body`.
- static func withReadHandle(forUrl url: URL, _ body: (FileHandle) throws -> T) throws -> T {
- let handle = try FileHandle(forReadingFrom: url)
- // Read-only handle — close errors (EBADF, EINTR) are harmless; no buffered writes to lose.
- defer { try? handle.close() }
- return try body(handle)
- }
-}
diff --git a/ios/Sources/GutenbergKitHTTP/HTTPRequestHandler.swift b/ios/Sources/GutenbergKitHTTP/HTTPRequestHandler.swift
deleted file mode 100644
index e4ec8c020..000000000
--- a/ios/Sources/GutenbergKitHTTP/HTTPRequestHandler.swift
+++ /dev/null
@@ -1,42 +0,0 @@
-#if canImport(Network)
-
-import Foundation
-
-/// Serves requests for an ``HTTPServer``.
-///
-/// The closure form of
-/// ``HTTPServer/start(name:port:listenOnAllInterfaces:requiresAuthentication:maxRequestBodySize:maxConnections:readTimeout:bodyReadTimeout:idleTimeout:startTimeout:cors:delegate:handler:)-(_,_,_,_,_,_,_,_,_,_,_,_,@escaping@Sendable(HTTPServer.Request)async->HTTPResponse)``
-/// is the right tool for a handler that needs no state. Conform to this instead when
-/// the handler has dependencies: they become stored properties, and the request
-/// methods become ordinary instance methods rather than statics threading a context
-/// parameter through every call.
-///
-/// ## Lifetimes
-///
-/// The server retains its handler for its lifetime, so a handler must not strongly
-/// hold the object that owns the server, directly or transitively:
-/// `owner → HTTPServer → handler → owner` is a cycle, the owner's `deinit` never runs,
-/// and `stop()` is never called — a silently stranded listener, not a crash.
-///
-/// A value type is **not** protection. A `struct` handler storing the owner closes the
-/// same ring: the server captures the struct into a heap node, and its stored properties
-/// are strong edges out of it. This protocol is deliberately **not** `AnyObject`-constrained
-/// so a handler *can* be a `struct` holding only what it needs — not because a `struct` is
-/// safe by construction. Either shape works; both must stay leaves, the same discipline
-/// ``HTTPServerDelegate`` documents.
-///
-/// The usual trap is the object that starts the server also serving it — a view controller
-/// starting it in `viewDidLoad` and stopping it in `deinit` is the shape that bites, because
-/// the cycle disables the very teardown meant to break it. Conform a separate leaf type, or
-/// call `stop()` from a hook that does run.
-public protocol HTTPRequestHandler: Sendable {
- /// The response for a request the server has parsed and authenticated.
- ///
- /// Called once per request, concurrently across connections — hence `Sendable`.
- /// Cancellation is cooperative: the server cancels this task when the client
- /// disconnects or the server stops, and discards whatever a cancelled task
- /// returns, so check `Task.isCancelled` before any side effect you can't undo.
- func handle(_ request: HTTPServer.Request) async -> HTTPResponse
-}
-
-#endif // canImport(Network)
diff --git a/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift b/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift
deleted file mode 100644
index f57b24529..000000000
--- a/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift
+++ /dev/null
@@ -1,465 +0,0 @@
-import Foundation
-
-/// Parses raw HTTP/1.1 request data into a structured `ParsedHTTPRequest`.
-///
-/// This parser handles incremental data — call `append(_:)` as bytes arrive,
-/// then check `state` to determine whether buffering is complete.
-///
-/// The parser buffers incoming data to a temporary file on disk rather than
-/// accumulating it in memory, making it suitable for large request bodies.
-/// If the temp file cannot be created (e.g. disk full), the parser falls back
-/// to in-memory buffering automatically.
-///
-/// State tracking is lightweight — `append(_:)` scans for the header separator
-/// (`\r\n\r\n`) and extracts `Content-Length`. Full parsing and RFC validation
-/// are deferred until ``parseRequest()`` is called.
-///
-/// ```swift
-/// let parser = HTTPRequestParser("GET /api HTTP/1.1\r\nHost: localhost\r\n\r\n")
-/// let request = try parser.parseRequest()
-/// print(request?.method, request?.target)
-/// ```
-public final class HTTPRequestParser: @unchecked Sendable {
-
- /// The current buffering state of the parser.
- public enum State: Sendable {
- /// More data is needed before headers are complete.
- case needsMoreData
- /// Headers have been fully received but the body is still incomplete.
- case headersComplete
- /// The request body exceeds the maximum allowed size and is being
- /// drained (read and discarded) so the server can send a clean 413
- /// response. No body bytes are buffered in this state.
- case draining
- /// All data has been received (headers and body).
- case complete
- }
-
- /// The default maximum request body size (4 GB).
- public static let defaultMaxBodySize: Int64 = Int64(4) * 1024 * 1024 * 1024
-
- /// The default threshold below which bodies are kept in memory (512 KB).
- public static let defaultInMemoryBodyThreshold: Int = 512 * 1024
-
- /// The maximum number of bytes to buffer before the header terminator is found (64 KB).
- /// This matches the `readFromBuffer` scan cap and prevents unbounded disk writes
- /// from clients that never send `\r\n\r\n`.
- static let maxHeaderSize: Int = 65536
-
- private let lock = NSLock()
- private var buffer: Buffer
- private let maxBodySize: Int64
- private let inMemoryBodyThreshold: Int
- private var bytesWritten: Int64 = 0
- private var _state: State = .needsMoreData
-
- // Lightweight scan results (populated by append)
- private var headerEndOffset: Int?
- private var expectedContentLength: Int64 = 0
-
- // Lazy parsing cache (populated by parseRequest)
- private var _parsedHeaders: HTTPRequestSerializer.ParsedHeaders?
- private var _parseError: HTTPRequestParseError?
- private var _cachedBody: RequestBody?
- private var _bodyExtracted: Bool = false
-
- /// Creates a new parser.
- ///
- /// - Parameters:
- /// - maxBodySize: The maximum allowed request body size in bytes.
- /// Requests with a `Content-Length` exceeding this will be rejected.
- /// Defaults to ``defaultMaxBodySize`` (4 GB).
- /// - inMemoryBodyThreshold: Bodies smaller than this are kept in memory;
- /// larger bodies are streamed to a temporary file. Defaults to
- /// ``defaultInMemoryBodyThreshold`` (512 KB).
- /// - tempDirectory: Directory for temporary files. Defaults to the system
- /// temp directory. When used via ``HTTPServer``, this is a server-specific
- /// subdirectory scoped by the server's `name`.
- public init(
- maxBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize,
- inMemoryBodyThreshold: Int = HTTPRequestParser.defaultInMemoryBodyThreshold,
- tempDirectory: URL? = nil
- ) {
- // Cap in-memory buffers at headers + inMemoryBodyThreshold to prevent
- // unbounded memory growth when temp file creation fails.
- self.buffer = Buffer(maxSize: Self.maxHeaderSize + inMemoryBodyThreshold, directory: tempDirectory)
- self.maxBodySize = maxBodySize
- self.inMemoryBodyThreshold = inMemoryBodyThreshold
- }
-
- /// Creates a parser and immediately parses the given raw HTTP string.
- ///
- /// This is a convenience for one-shot parsing when all data is available upfront.
- public convenience init(_ string: String) {
- self.init(Data(string.utf8))
- }
-
- /// Creates a parser and immediately parses the given raw HTTP data.
- ///
- /// This is a convenience for one-shot parsing when all data is available upfront.
- public convenience init(_ data: Data) {
- self.init()
- append(data)
- }
-
- /// The current buffering state.
- public var state: State {
- lock.withLock { _state }
- }
-
- /// The parse error detected during buffering, if any.
- ///
- /// Non-fatal errors like ``HTTPRequestParseError/payloadTooLarge`` are
- /// exposed here instead of being thrown by ``parseRequest()``, allowing
- /// the caller to still access the parsed headers.
- public var parseError: HTTPRequestParseError? {
- lock.withLock { _parseError }
- }
-
- /// The expected body length from `Content-Length`, available once headers have been received.
- public var expectedBodyLength: Int64? {
- lock.withLock {
- guard _state.hasHeaders else { return nil }
- return expectedContentLength
- }
- }
-
- /// Parses the buffered data into a structured HTTP request.
- ///
- /// This triggers full parsing via ``HTTPRequestSerializer`` on the first call.
- /// The parsed headers are cached for subsequent calls. When the state is
- /// `.complete` and a body is present, the body is extracted to a temporary
- /// file on the first access.
- ///
- /// - Returns: The parsed request, or `nil` if the state is `.needsMoreData`.
- /// - Throws: ``HTTPRequestParseError`` if the request is malformed.
- public func parseRequest() throws -> ParsedHTTPRequest? {
- try lock.withLock {
- guard _state.hasHeaders else { return nil }
-
- // Recoverable errors (e.g. payloadTooLarge — valid headers, rejected
- // body) are surfaced to the caller so the handler can build a
- // response. Fatal errors indicate genuinely malformed requests and
- // are thrown, closing the connection before the handler runs.
- if let error = _parseError, error.disposition == .fatal {
- throw error
- }
-
- if _parsedHeaders == nil {
- let headerData = try buffer.read(from: 0, maxLength: Int(min(bytesWritten, Int64(Self.maxHeaderSize))))
- switch HTTPRequestSerializer.parseHeaders(from: headerData) {
- case .parsed(let headers):
- _parsedHeaders = headers
- case .invalid(let error):
- _parseError = error
- throw error
- case .needsMoreData:
- return nil
- }
- }
-
- guard let headers = _parsedHeaders else { return nil }
-
- // Return partial (headers only) when the body was rejected or
- // hasn't fully arrived yet. The payloadTooLarge case goes through
- // drain mode which discards body bytes without buffering them, so
- // there is no body to extract even though the state is .complete.
- guard _state.isComplete, _parseError == nil else {
- return .partial(
- method: headers.method,
- target: headers.target,
- httpVersion: headers.httpVersion,
- headers: headers.headers
- )
- }
-
- if headers.contentLength > 0 && !_bodyExtracted {
- _cachedBody = try extractBody(
- offset: headers.bodyOffset,
- length: headers.contentLength
- )
- _bodyExtracted = true
- }
-
- return .complete(
- method: headers.method,
- target: headers.target,
- httpVersion: headers.httpVersion,
- headers: headers.headers,
- body: _cachedBody
- )
- }
- }
-
- /// Appends received data to the buffer and updates the buffering state.
- ///
- /// This method performs lightweight scanning — it looks for the `\r\n\r\n`
- /// header separator and extracts the `Content-Length` value. Full parsing
- /// and RFC validation are deferred until ``parseRequest()`` is called.
- public func append(_ data: Data) {
- lock.withLock {
- guard !_state.isComplete else { return }
-
- // In drain mode, discard bytes without buffering and check
- // whether the full Content-Length has been consumed.
- if case .draining = _state {
- bytesWritten += Int64(data.count)
- if let offset = headerEndOffset,
- bytesWritten - Int64(offset) >= expectedContentLength {
- _state = .complete
- }
- return
- }
-
- let accepted: Bool
- do {
- accepted = try buffer.append(data)
- } catch {
- _parseError = .bufferIOError
- _state = .complete
- return
- }
- guard accepted else {
- _parseError = .payloadTooLarge
- _state = .complete
- return
- }
- bytesWritten += Int64(data.count)
-
- if headerEndOffset == nil {
- let buffered: Data
- do {
- buffered = try buffer.read(from: 0, maxLength: Int(min(bytesWritten, Int64(Self.maxHeaderSize))))
- } catch {
- _parseError = .bufferIOError
- _state = .complete
- return
- }
- let separator = Data("\r\n\r\n".utf8)
-
- // RFC 7230 §3.5: Skip leading CRLFs for robustness.
- var scanStart = 0
- while scanStart + 1 < buffered.count,
- buffered[scanStart] == 0x0D,
- buffered[scanStart + 1] == 0x0A {
- scanStart += 2
- }
- let effectiveData = buffered[scanStart...]
-
- guard let separatorRange = effectiveData.range(of: separator) else {
- if bytesWritten > Int64(Self.maxHeaderSize) {
- _parseError = .headersTooLarge
- _state = .complete
- } else {
- _state = .needsMoreData
- }
- return
- }
-
- headerEndOffset = buffered.distance(from: buffered.startIndex, to: separatorRange.upperBound)
- let headerBytes = effectiveData[effectiveData.startIndex.. maxBodySize {
- _parseError = .payloadTooLarge
- // Check if the body bytes already received in this
- // chunk satisfy the drain — small requests may arrive
- // as a single read.
- if let offset = headerEndOffset,
- bytesWritten - Int64(offset) >= expectedContentLength {
- _state = .complete
- } else {
- _state = .draining
- }
- return
- }
- }
-
- guard let offset = headerEndOffset else { return }
- let bodyBytesAvailable = bytesWritten - Int64(offset)
-
- if bodyBytesAvailable >= expectedContentLength {
- _state = .complete
- } else {
- _state = .headersComplete
- }
- }
- }
-
- // MARK: - Content-Length Scanning
-
- /// Extracts and validates the `Content-Length` value from header bytes without full parsing.
- ///
- /// This reuses ``HTTPRequestSerializer/validateContentLength(_:existing:)`` so that
- /// the scan and the later full parse apply identical validation rules. Conflicting
- /// or malformed values are rejected immediately — before any body bytes are buffered.
- ///
- /// Returns 0 if no `Content-Length` header is present.
- private static func scanContentLength(in headerBytes: Data) throws(HTTPRequestParseError) -> Int64 {
- guard let string = String(data: headerBytes, encoding: .utf8) else { return 0 }
- let lines = string.components(separatedBy: "\r\n")
-
- var contentLength: Int64?
- for line in lines.dropFirst() where !line.isEmpty {
- guard let colonIndex = line.firstIndex(of: ":") else { continue }
- let rawKey = line[line.startIndex.. RequestBody? {
- if length <= inMemoryBodyThreshold {
- return RequestBody(data: try buffer.read(from: offset, maxLength: Int(length)))
- }
-
- // Reference the body range directly in the buffer's file.
- if let (fileURL, owner) = buffer.transferFileOwnership() {
- return RequestBody(
- fileURL: fileURL,
- offset: UInt64(offset),
- length: Int(length),
- owner: owner
- )
- }
-
- // Memory-backed buffer — read into a Data.
- return RequestBody(data: try buffer.read(from: offset, maxLength: Int(length)))
- }
-}
-
-// MARK: - Buffer
-
-/// Abstraction over the parser's backing store.
-///
-/// Tries to use a temp file on disk (suitable for large bodies). If the file
-/// cannot be created, falls back to an in-memory `Data` buffer automatically.
-/// When memory-backed, the buffer is capped at `maxSize` to prevent unbounded growth.
-private final class Buffer {
- private let fileURL: URL?
- private let fileHandle: FileHandle?
- private var memoryBuffer: Data?
- private var fileOwnershipTransferred = false
- private let maxSize: Int
-
- /// Whether the buffer is backed by memory rather than a file.
- var isMemoryBacked: Bool { fileHandle == nil }
-
- init(maxSize: Int, directory: URL? = nil) {
- self.maxSize = maxSize
-
- let dir = directory ?? FileManager.default.temporaryDirectory
- let url = dir.appendingPathComponent("GutenbergKitHTTP-\(UUID().uuidString)")
-
- // Mark the file active before creating it, so a concurrent server's orphan
- // sweep can't delete it in the window between creation and first use.
- ActiveTempFiles.register(url.lastPathComponent)
-
- if FileManager.default.createFile(atPath: url.path, contents: nil),
- let handle = FileHandle(forUpdatingAtPath: url.path) {
- self.fileURL = url
- self.fileHandle = handle
- self.memoryBuffer = nil
- } else {
- // Temp file unavailable — buffer in memory instead.
- ActiveTempFiles.unregister(url.lastPathComponent)
- self.fileURL = nil
- self.fileHandle = nil
- self.memoryBuffer = Data()
- }
- }
-
- deinit {
- if let fileHandle {
- // Writable handle, but the file is a temp buffer deleted immediately below —
- // an EIO on close cannot cause data loss here.
- try? fileHandle.close()
- }
- if let fileURL, !fileOwnershipTransferred {
- ActiveTempFiles.unregister(fileURL.lastPathComponent)
- try? FileManager.default.removeItem(at: fileURL)
- }
- }
-
- /// Transfers ownership of the backing file to a `TempFileOwner`.
- ///
- /// After this call, the buffer will no longer delete the file on deinit.
- /// Returns `nil` if the buffer is memory-backed or ownership was already transferred.
- func transferFileOwnership() -> (URL, TempFileOwner)? {
- guard let fileURL, !fileOwnershipTransferred else { return nil }
- fileOwnershipTransferred = true
- return (fileURL, TempFileOwner(url: fileURL))
- }
-
- /// Appends data to the buffer.
- ///
- /// - Returns: `true` if the data was accepted, `false` if the in-memory
- /// buffer would exceed its size limit.
- /// - Throws: If the file-backed write fails (e.g. disk full).
- @discardableResult
- func append(_ data: Data) throws -> Bool {
- if let fileHandle {
- try fileHandle.seekToEnd()
- try fileHandle.write(contentsOf: data)
- return true
- } else {
- if memoryBuffer!.count + data.count > maxSize {
- return false
- }
- memoryBuffer!.append(data)
- return true
- }
- }
-
- func read(from offset: Int, maxLength: Int) throws -> Data {
- precondition(offset >= 0, "offset must be non-negative, was \(offset)")
- precondition(maxLength >= 0, "maxLength must be non-negative, was \(maxLength)")
- if maxLength == 0 { return Data() }
- if let fileHandle {
- try fileHandle.seek(toOffset: UInt64(offset))
- return try fileHandle.read(upToCount: maxLength) ?? Data()
- } else {
- let start = memoryBuffer!.startIndex + offset
- let end = min(start + maxLength, memoryBuffer!.endIndex)
- return Data(memoryBuffer![start.. HeaderParseResult {
- // Ensure zero-based indexing — Data slices retain their original indices,
- // so a caller passing e.g. `fullData[500...]` would crash on `data[0]`.
- let data = Data(data)
-
- // RFC 7230 §3.5: Skip leading CRLFs for robustness.
- // A server SHOULD ignore at least one empty line received prior to the request-line.
- var scanOffset = 0
- while scanOffset + 1 < data.count,
- data[scanOffset] == 0x0D,
- data[scanOffset + 1] == 0x0A {
- scanOffset += 2
- }
- guard scanOffset < data.count else {
- return .needsMoreData
- }
- let effectiveData = data[scanOffset...]
-
- let separator = Data("\r\n\r\n".utf8)
- guard let separatorRange = effectiveData.range(of: separator) else {
- return .needsMoreData
- }
-
- let headerData = effectiveData[effectiveData.startIndex..= 2 else {
- return .invalid(.malformedRequestLine)
- }
-
- let method = String(parts[0])
- let target = String(parts[1])
-
- // RFC 9110 §9.1: method = token (tchar characters only).
- guard method.allSatisfy({ isTokenChar($0) }) else {
- return .invalid(.malformedRequestLine)
- }
-
- // RFC 9112 §2.3: HTTP-version = "HTTP/" DIGIT "." DIGIT
- guard parts.count >= 3 else {
- return .invalid(.invalidHTTPVersion)
- }
- let httpVersion = String(parts[2])
- guard isValidHTTPVersion(httpVersion) else {
- return .invalid(.invalidHTTPVersion)
- }
-
- // RFC 9112 §3.2: Validate request-target form.
- // origin-form: starts with "/"
- // absolute-form: starts with a scheme (e.g. "http://", "https://")
- // asterisk-form: "*" (only valid for OPTIONS)
- // authority-form: only valid for CONNECT
- if method == "CONNECT" {
- // authority-form: host:port — must contain a colon and not start with "/"
- if target.hasPrefix("/") || !target.contains(":") {
- return .invalid(.malformedRequestLine)
- }
- } else if method == "OPTIONS" && target == "*" {
- // asterisk-form is valid for OPTIONS
- } else if target.hasPrefix("/") {
- // origin-form — valid for all methods
- } else if target.lowercased().hasPrefix("http://") || target.lowercased().hasPrefix("https://") {
- // absolute-form — valid for all methods
- } else {
- return .invalid(.malformedRequestLine)
- }
-
- var headers: [String: String] = [:]
- var keyIndex: [String: String] = [:] // lowercased -> original casing
- var contentLengthValue: Int64?
- var hostHeaderCount = 0
- var headerCount = 0
- for line in lines.dropFirst() where !line.isEmpty {
- headerCount += 1
- if headerCount > 100 {
- return .invalid(.tooManyHeaders)
- }
- // RFC 7230 §3.2.4: Reject obs-fold (continuation line starting with SP or HTAB)
- if line.first == " " || line.first == "\t" {
- return .invalid(.obsFoldDetected)
- }
-
- guard let colonIndex = line.firstIndex(of: ":") else {
- // RFC 9112 §5: A line with content but no colon is not a valid field line.
- return .invalid(.invalidFieldName)
- }
-
- let rawKey = line[line.startIndex..= 0x0A && v <= 0x1F) || v == 0x7F {
- return .invalid(.invalidFieldValue)
- }
- }
-
- // RFC 7230 §3.3.3: Reject requests with Transfer-Encoding since this
- // server does not support chunked decoding. Silently ignoring it would
- // cause body framing mismatches (request smuggling).
- if lowerKey == "transfer-encoding" {
- return .invalid(.unsupportedTransferEncoding)
- }
-
- // Content-Length: validate and normalize to a single integer value.
- if lowerKey == "content-length" {
- do {
- contentLengthValue = try validateContentLength(value, existing: contentLengthValue)
- } catch {
- return .invalid(error)
- }
- // Store the resolved integer as the canonical header value,
- // not the raw (possibly comma-separated) form.
- let resolved = String(contentLengthValue!)
- if let existingKey = keyIndex["content-length"] {
- headers[existingKey] = resolved
- } else {
- headers[key] = resolved
- keyIndex["content-length"] = key
- }
- continue
- }
-
- // Track Host header occurrences for RFC 9110 §7.2 validation.
- if lowerKey == "host" {
- hostHeaderCount += 1
- }
-
- // RFC 9110 §5.3: Combine duplicate field lines with comma-separated values.
- if let existingKey = keyIndex[lowerKey] {
- headers[existingKey] = "\(headers[existingKey]!), \(value)"
- } else {
- headers[key] = value
- keyIndex[lowerKey] = key
- }
- }
-
- // RFC 9110 §7.2: Reject requests with multiple Host headers (any version)
- // or missing Host header (HTTP/1.1 only).
- if hostHeaderCount > 1 {
- return .invalid(.multipleHostHeaders)
- }
- if httpVersion == "HTTP/1.1" && hostHeaderCount == 0 {
- return .invalid(.missingHostHeader)
- }
-
- let contentLength = contentLengthValue ?? 0
-
- let bodyOffset = data.distance(from: data.startIndex, to: separatorRange.upperBound)
-
- return .parsed(ParsedHeaders(
- method: method,
- target: target,
- httpVersion: httpVersion,
- headers: headers,
- contentLength: contentLength,
- bodyOffset: bodyOffset
- ))
- }
-
- /// Validates a Content-Length header value per RFC 9110 §8.6 / RFC 7230 §3.3.3.
- ///
- /// A Content-Length value may be a single number or a comma-separated list of
- /// identical values (e.g. "5, 5"). Each element must be a non-negative decimal
- /// integer (ASCII digits only — no +, ., 0x, etc.).
- ///
- /// - Parameters:
- /// - value: The raw header value string.
- /// - existing: A previously parsed Content-Length value, if any.
- /// - Returns: The validated content length as an integer.
- /// - Throws: ``HTTPRequestParseError/invalidContentLength`` or ``HTTPRequestParseError/conflictingContentLength``.
- static func validateContentLength(_ value: String, existing: Int64?) throws(HTTPRequestParseError) -> Int64 {
- let parts = value.split(separator: ",", omittingEmptySubsequences: false).map {
- $0.trimmingCharacters(in: .whitespaces)
- }
- guard let first = parts.first,
- !first.isEmpty,
- first.allSatisfy({ $0.isASCII && $0.isNumber }),
- let cl = Int64(first),
- cl >= 0
- else {
- throw HTTPRequestParseError.invalidContentLength
- }
- // All parts in a comma-separated list must represent the same integer value
- for part in parts.dropFirst() {
- guard !part.isEmpty,
- part.allSatisfy({ $0.isASCII && $0.isNumber }),
- let partValue = Int64(part),
- partValue == cl
- else {
- throw HTTPRequestParseError.conflictingContentLength
- }
- }
- if let existing, existing != cl {
- throw HTTPRequestParseError.conflictingContentLength
- }
- return cl
- }
-
- /// Validates that a string matches the HTTP-version format: `HTTP/DIGIT.DIGIT`.
- private static func isValidHTTPVersion(_ version: String) -> Bool {
- let prefix = "HTTP/"
- guard version.hasPrefix(prefix) else { return false }
- let rest = version.dropFirst(prefix.count)
- let parts = rest.split(separator: ".", maxSplits: 1)
- guard parts.count == 2,
- parts[0].count == 1, parts[0].first?.isASCII == true, parts[0].first?.isNumber == true,
- parts[1].count == 1, parts[1].first?.isASCII == true, parts[1].first?.isNumber == true
- else { return false }
- return true
- }
-
- /// Returns whether a character is a valid HTTP token character (RFC 9110 §5.6.2).
- ///
- /// `tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
- /// "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA`
- private static func isTokenChar(_ c: Character) -> Bool {
- guard let ascii = c.asciiValue else { return false }
- switch ascii {
- case UInt8(ascii: "A")...UInt8(ascii: "Z"),
- UInt8(ascii: "a")...UInt8(ascii: "z"),
- UInt8(ascii: "0")...UInt8(ascii: "9"):
- return true
- case UInt8(ascii: "!"), UInt8(ascii: "#"), UInt8(ascii: "$"), UInt8(ascii: "%"),
- UInt8(ascii: "&"), UInt8(ascii: "'"), UInt8(ascii: "*"), UInt8(ascii: "+"),
- UInt8(ascii: "-"), UInt8(ascii: "."), UInt8(ascii: "^"), UInt8(ascii: "_"),
- UInt8(ascii: "`"), UInt8(ascii: "|"), UInt8(ascii: "~"):
- return true
- default:
- return false
- }
- }
-}
diff --git a/ios/Sources/GutenbergKitHTTP/HTTPResponse.swift b/ios/Sources/GutenbergKitHTTP/HTTPResponse.swift
deleted file mode 100644
index 39e88e146..000000000
--- a/ios/Sources/GutenbergKitHTTP/HTTPResponse.swift
+++ /dev/null
@@ -1,234 +0,0 @@
-import Foundation
-
-/// Converts a `StaticString` to a `String`.
-///
-/// The Swift standard library does not provide a direct `String.init(_ : StaticString)` initializer.
-/// This extension bridges that gap so that APIs returning `StaticString` for pointer-stability
-/// reasons (e.g., `HTTPResponse.defaultStatusText`, `HTTPRequestParseError.httpStatusText`) can
-/// be used naturally where a `String` is expected.
-extension String {
- init(_ staticString: StaticString) {
- self = staticString.withUTF8Buffer { String(decoding: $0, as: UTF8.self) }
- }
-}
-
-/// An HTTP response to send back to a client.
-public struct HTTPResponse: Sendable {
-
- /// The HTTP status code (e.g., 200, 404, 500).
- public let status: Int
-
- /// The HTTP reason phrase (e.g., "OK", "Not Found").
- ///
- /// When no explicit override was provided at init, this returns the standard
- /// phrase for `status` from `defaultStatusText(for:)`.
- public var statusText: String {
- statusTextOverride ?? String(Self.defaultStatusText(for: status))
- }
-
- /// Additional response headers. `Content-Length` is always set to the actual body size
- /// during serialization (any caller-provided value is replaced). `Connection` is added
- /// automatically if not already present.
- public let headers: [(String, String)]
-
- /// The response body.
- ///
- /// The entire body is held in memory. This is fine for the current use case
- /// (Gutenberg REST API payloads — JSON, HTML, CSS, JS) which are small. If
- /// large responses (e.g., media downloads) need to be proxied in the future,
- /// this could be replaced with a streaming abstraction similar to `RequestBody`.
- public let body: Data
-
- /// Caller-provided reason phrase override, or `nil` to use the default.
- private let statusTextOverride: String?
-
- /// Creates an HTTP response.
- ///
- /// - Parameters:
- /// - status: The HTTP status code.
- /// - statusText: The reason phrase. Defaults to a standard phrase for common status codes.
- /// - headers: Additional headers to include. Defaults to `Content-Type: text/plain`.
- /// - body: The response body. Defaults to empty.
- public init(
- status: Int,
- statusText: String? = nil,
- headers: [(String, String)] = [("Content-Type", "text/plain")],
- body: Data = Data()
- ) {
- self.status = status
- self.statusTextOverride = statusText
- self.headers = headers
- self.body = body
- }
-
- #if canImport(Network)
- /// RFC 9110 §7.6.1: hop-by-hop headers that must not be forwarded by proxies.
- private static let responseHopByHop: Set = [
- "connection", "transfer-encoding", "keep-alive",
- "proxy-connection", "te", "upgrade", "trailer",
- ]
-
- public init(_ response: (Data, URLResponse)) {
- guard let httpResponse = response.1 as? HTTPURLResponse else {
- self.status = 502
- self.statusTextOverride = "Bad Gateway"
- self.headers = [("Content-Type", "text/plain")]
- self.body = Data("Upstream returned a non-HTTP response".utf8)
- return
- }
- self.status = httpResponse.statusCode
- self.statusTextOverride = nil
-
- // Strip hop-by-hop headers (RFC 9110 §7.6.1) and the upstream Content-Length,
- // then set Content-Length from the actual body size. This ensures `headers` is
- // always truthful — consumers reading it directly (without going through
- // `serialized()`) won't see a stale upstream value.
- let upstream = (httpResponse.allHeaderFields as? [String: String] ?? [:])
- self.headers = upstream.compactMap { key, value in
- let lower = key.lowercased()
- guard !Self.responseHopByHop.contains(lower),
- lower != "content-length" else { return nil }
- return (key, value)
- } + [("Content-Length", "\(response.0.count)")]
- self.body = response.0
- }
- #endif
-
- /// Headers excluded during serialization: hop-by-hop headers (RFC 9110 §7.6.1)
- /// plus headers that are always recalculated (Content-Length, Date, Server).
- private static let serializationExcluded: Set = [
- "content-length", "connection", "transfer-encoding", "keep-alive",
- "proxy-connection", "te", "upgrade", "trailer",
- "date", "server",
- ]
-
- /// Serializes the response into raw HTTP/1.1 bytes ready to send on the wire.
- public func serialized() -> Data {
- var allHeaders = headers.filter { !Self.serializationExcluded.contains($0.0.lowercased()) }
- allHeaders.append(("Content-Length", "\(body.count)"))
- allHeaders.append(("Connection", "close"))
- allHeaders.append(("Date", Self.httpDate()))
- allHeaders.append(("Server", "GutenbergKit"))
-
- // Strip CR/LF from header names and values to prevent header injection.
- let headerString = allHeaders.map { "\(Self.sanitize($0.0)): \(Self.sanitize($0.1))" }.joined(separator: "\r\n")
- // RFC 9112 §4: status-code = 3DIGIT — always zero-pad to 3 digits.
- let statusCode = String(format: "%03d", min(max(status, 0), 999))
- let head = "HTTP/1.1 \(statusCode) \(Self.sanitize(statusText))\r\n\(headerString)\r\n\r\n"
-
- var data = Data(head.utf8)
- data.append(body)
- return data
- }
-
- /// Removes control characters (including CR, LF, NUL, BEL, etc.) to prevent
- /// HTTP response header injection and malformed output per RFC 9112 §4.
- /// HTAB (0x09) and non-ASCII characters (obs-text, 0x80+) are preserved,
- /// as RFC 9110 §5.5 explicitly allows them in header field values.
- private static func sanitize(_ value: String) -> String {
- value.filter { char in
- guard let ascii = char.asciiValue else { return true } // Keep non-ASCII
- if ascii == 0x09 { return true } // Keep HTAB
- return ascii >= 0x20 && ascii != 0x7F // Strip CTLs and DEL
- }
- }
-
- private static let httpDateLock = NSLock()
- private static let httpDateFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "en_US_POSIX")
- formatter.timeZone = TimeZone(identifier: "GMT")
- formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'"
- return formatter
- }()
-
- /// Formats the current time as an HTTP-date per RFC 9110 §5.6.7.
- private static func httpDate() -> String {
- httpDateLock.lock()
- defer { httpDateLock.unlock() }
- return httpDateFormatter.string(from: Date())
- }
-
- /// Returns this response's reason phrase as a `StaticString`.
- ///
- /// This is the same table as `defaultStatusText(for:)`, exposed for callers
- /// (like the JNI bridge) that need a stable pointer without allocation.
- var staticStatusText: StaticString {
- Self.defaultStatusText(for: status)
- }
-
- /// Standard English reason phrases per RFC 9110 / RFC 9112 §4.
- ///
- /// This avoids `HTTPURLResponse.localizedString(forStatusCode:)` which may
- /// return locale-dependent translations.
- static func defaultStatusText(for status: Int) -> StaticString {
- switch status {
- // 1xx Informational
- case 100: "Continue"
- case 101: "Switching Protocols"
- case 102: "Processing"
- case 103: "Early Hints"
- // 2xx Success
- case 200: "OK"
- case 201: "Created"
- case 202: "Accepted"
- case 203: "Non-Authoritative Information"
- case 204: "No Content"
- case 205: "Reset Content"
- case 206: "Partial Content"
- case 207: "Multi-Status"
- case 208: "Already Reported"
- case 226: "IM Used"
- // 3xx Redirection
- case 300: "Multiple Choices"
- case 301: "Moved Permanently"
- case 302: "Found"
- case 303: "See Other"
- case 304: "Not Modified"
- case 307: "Temporary Redirect"
- case 308: "Permanent Redirect"
- // 4xx Client Error
- case 400: "Bad Request"
- case 401: "Unauthorized"
- case 402: "Payment Required"
- case 403: "Forbidden"
- case 404: "Not Found"
- case 405: "Method Not Allowed"
- case 406: "Not Acceptable"
- case 407: "Proxy Authentication Required"
- case 408: "Request Timeout"
- case 409: "Conflict"
- case 410: "Gone"
- case 411: "Length Required"
- case 412: "Precondition Failed"
- case 413: "Content Too Large"
- case 414: "URI Too Long"
- case 415: "Unsupported Media Type"
- case 416: "Range Not Satisfiable"
- case 417: "Expectation Failed"
- case 421: "Misdirected Request"
- case 422: "Unprocessable Content"
- case 423: "Locked"
- case 424: "Failed Dependency"
- case 425: "Too Early"
- case 426: "Upgrade Required"
- case 428: "Precondition Required"
- case 429: "Too Many Requests"
- case 431: "Request Header Fields Too Large"
- case 451: "Unavailable For Legal Reasons"
- // 5xx Server Error
- case 500: "Internal Server Error"
- case 501: "Not Implemented"
- case 502: "Bad Gateway"
- case 503: "Service Unavailable"
- case 504: "Gateway Timeout"
- case 505: "HTTP Version Not Supported"
- case 506: "Variant Also Negotiates"
- case 507: "Insufficient Storage"
- case 508: "Loop Detected"
- case 510: "Not Extended"
- case 511: "Network Authentication Required"
- default: "Unknown"
- }
- }
-}
diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift
deleted file mode 100644
index be7001e18..000000000
--- a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift
+++ /dev/null
@@ -1,997 +0,0 @@
-#if canImport(Network)
-
-import Foundation
-import Network
-import OSLog
-
-/// A lightweight local HTTP/1.1 server built on Network.framework.
-///
-/// The server binds to `127.0.0.1` on a specified or system-assigned port and
-/// dispatches each incoming request to a caller-provided handler. Requests are
-/// parsed incrementally using ``HTTPRequestParser``, so large bodies are buffered
-/// to disk rather than held in memory.
-///
-/// ```swift
-/// let server = try await HTTPServer.start(name: "media-proxy", port: 0) { req in
-/// print("\(req.parsed.method) \(req.parsed.target) (\(req.parseDuration))")
-/// return HTTPResponse(status: 200, body: Data("OK".utf8))
-/// }
-/// print("Listening on port \(server.port)")
-/// // ...
-/// server.stop()
-/// ```
-///
-/// ## Security
-///
-/// The server itself is a generic request dispatcher — it does not forward
-/// requests or act as a proxy. SSRF protection is intentionally left to the
-/// `handler` implementation, since the server cannot know which upstream hosts
-/// are legitimate. The server provides two layers of defence by default:
-///
-/// 1. Binds to `127.0.0.1` (localhost only) unless `listenOnAllInterfaces` is set.
-/// 2. Requires a randomly-generated bearer token on every request (when
-/// `requiresAuthentication` is enabled). Accepts the token in either
-/// `Proxy-Authorization` (RFC 9110 §11.7.1, for native clients) or
-/// `Relay-Authorization` (for browser `fetch()`, where `Proxy-*` headers
-/// are forbidden). Both keep `Authorization` free for upstream credentials.
-///
-/// ## CORS
-///
-/// When `requiresAuthentication` is enabled, `OPTIONS` requests are exempt
-/// from authentication because CORS preflight requests never include
-/// credentials (Fetch spec §3.3.5). However, the server does not generate
-/// CORS response headers — this is the handler's responsibility.
-///
-/// When proxying to a remote server, the upstream response will typically
-/// include the correct CORS headers already — pass it through unaltered.
-/// When serving local content, the handler must return appropriate headers
-/// for `OPTIONS` requests, typically:
-///
-/// Access-Control-Allow-Origin:
-/// Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
-/// Access-Control-Allow-Headers: Authorization, Relay-Authorization, Content-Type
-/// Access-Control-Max-Age: 86400
-///
-/// Without these headers, browsers will reject the preflight and block
-/// the actual request. A handler that returns 404 for unrecognized methods
-/// will silently break CORS for browser clients.
-///
-/// ## Connection Model
-///
-/// Each connection handles exactly one request (`Connection: close`). HTTP
-/// keep-alive / pipelining is intentionally unsupported. This simplifies body
-/// framing — in particular, GET/DELETE requests with unexpected body data are
-/// safe because leftover bytes are discarded when the connection closes. If
-/// keep-alive were ever added, body framing for all methods would need to be
-/// enforced to prevent request smuggling.
-///
-/// Lifecycle is managed explicitly: call ``stop()`` when the server is no longer
-/// needed, or let `deinit` cancel the listener.
-public final class HTTPServer: Sendable {
-
- /// A received HTTP request with server-side metadata.
- public struct Request: Sendable {
- /// The parsed HTTP request.
- public let parsed: ParsedHTTPRequest
- /// Time spent receiving and parsing the request.
- public let parseDuration: Duration
-
- init(parsed: ParsedHTTPRequest, parseDuration: Duration) {
- self.parsed = parsed
- self.parseDuration = parseDuration
- }
- }
-
- public typealias Response = HTTPResponse
-
- public typealias Error = HTTPServerError
-
- /// The port the server is listening on.
- public let port: UInt16
-
- /// A bearer token required on every request (via `Proxy-Authorization`
- /// or `Relay-Authorization`). Generated randomly on each server start.
- public let token: String
-
- private let listener: NWListener
- private let queue: DispatchQueue
- private let connectionTasks: ConnectionTasks
-
- /// Sweeps crash-orphaned temp files off the caller's startup path.
- /// Exposed so tests can await completion.
- let cleanupTask: Task
-
- private init(listener: NWListener, port: UInt16, queue: DispatchQueue, token: String, connectionTasks: ConnectionTasks, cleanupTask: Task) {
- self.listener = listener
- self.port = port
- self.queue = queue
- self.token = token
- self.connectionTasks = connectionTasks
- self.cleanupTask = cleanupTask
- }
-
- /// The default maximum number of concurrent connections.
- public static let defaultMaxConnections: Int = 5
-
- /// The default read timeout for receiving a complete request (30 seconds).
- public static let defaultReadTimeout: Duration = .seconds(30)
-
- /// The default idle timeout between consecutive reads (5 seconds).
- /// If no data arrives within this interval, the connection is closed with a 408 response.
- public static let defaultIdleTimeout: Duration = .seconds(5)
-
- /// The default ceiling on waiting for the listener to become ready (5 seconds).
- /// A loopback bind completes near-instantly; this only bounds a pathological
- /// listener stuck in a non-terminal state so the caller isn't hung forever.
- public static let defaultStartTimeout: Duration = .seconds(5)
-
- /// The maximum number of bytes to read from the network in a single receive call.
- private static let readChunkSize: Int = 65536
-
- /// Creates and starts a new HTTP server.
- ///
- /// - Parameters:
- /// - name: A stable identifier for this server instance. Must be consistent across
- /// runs of the same logical server. Used to:
- /// - Namespace temporary files so that multiple server instances don't interfere
- /// with each other's orphan cleanup.
- /// - Label the server's dispatch queue (`com.gutenbergkit.http-server.`).
- ///
- /// Each distinct server should have a unique name. It is the caller's responsibility
- /// to choose a descriptive, collision-free identifier (e.g. `"media-proxy"`,
- /// `"editor-assets"`).
- /// - port: The port to listen on. Pass `nil` or omit to let the system assign an available port.
- /// - maxRequestBodySize: The maximum allowed request body size in bytes.
- /// Requests exceeding this limit receive a 413 response. Defaults to 4 GB.
- /// - maxConnections: The maximum number of concurrent connections. New connections
- /// beyond this limit are immediately closed. Defaults to 5.
- /// - readTimeout: The maximum time to wait for the pre-body phase of a request —
- /// receiving the headers and draining any oversized body — before closing the
- /// connection. This bounds the unauthenticated-reachable portion of the request.
- /// Defaults to 30 seconds.
- /// - bodyReadTimeout: The maximum total time to wait for an accepted (authenticated)
- /// request body, as a backstop above the per-read `idleTimeout`. A large body that
- /// streams steadily is bounded by this ceiling rather than by `readTimeout`, so it
- /// is not aborted mid-transfer. Pass `nil` (the default) to reuse `readTimeout`;
- /// consumers expecting large uploads should pass a generous value.
- /// - idleTimeout: The maximum time to wait between consecutive reads before closing
- /// the connection. Prevents slow-loris attacks. Defaults to 5 seconds.
- /// - startTimeout: The maximum time to wait for the listener to become ready
- /// before giving up with ``HTTPServerError/failedToStart``. Bounds a listener
- /// stuck in the `.waiting` state. Defaults to 5 seconds.
- /// - handler: A closure invoked for each fully-parsed request. Return an ``HTTPResponse``
- /// to send back to the client.
- /// - Returns: A running ``HTTPServer`` instance.
- /// - Throws: ``HTTPServerError/failedToStart`` if the listener cannot bind to the port
- /// or does not become ready within `startTimeout`.
- public static func start(
- name: String,
- port: UInt16? = nil,
- listenOnAllInterfaces: Bool = false,
- requiresAuthentication: Bool = true,
- maxRequestBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize,
- maxConnections: Int = HTTPServer.defaultMaxConnections,
- readTimeout: Duration = HTTPServer.defaultReadTimeout,
- bodyReadTimeout: Duration? = nil,
- idleTimeout: Duration = HTTPServer.defaultIdleTimeout,
- startTimeout: Duration = HTTPServer.defaultStartTimeout,
- cors: CORSPolicy = .none,
- delegate: HTTPServerDelegate? = nil,
- handler: @escaping @Sendable (HTTPServer.Request) async -> HTTPResponse
- ) async throws -> HTTPServer {
- // Sanitize to prevent path traversal — only allow safe filename characters.
- let safeName = sanitizeName(name)
-
- // Temp files are namespaced into a server-specific subdirectory so that
- // multiple server instances (with different names) don't interfere with
- // each other's orphan cleanup.
- let tempDirectory = FileManager.default.temporaryDirectory
- .appendingPathComponent("GutenbergKitHTTP-\(safeName)")
-
- // Clean up temp files left behind by previous runs (e.g., crash or process
- // kill), off the caller's startup path. Swift's ARC guarantees deterministic
- // cleanup during normal operation, but a crash can leave orphaned files in
- // the system temp directory. The sweep's one-hour age threshold means it
- // cannot race temp files written by this (or any live) server instance.
- let cleanupTask = Task.detached(priority: .utility) {
- cleanOrphanedTempFiles(in: tempDirectory)
- }
- try? FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
-
- let parameters = NWParameters.tcp
- let requestedPort = NWEndpoint.Port(rawValue: port ?? 0) ?? .any
- let host: NWEndpoint.Host = listenOnAllInterfaces ? .ipv4(.any) : .ipv4(.loopback)
- parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: host, port: requestedPort)
-
- let token = generateToken()
- let connectionCounter = ConnectionCounter(limit: maxConnections)
- let connectionTasks = ConnectionTasks()
- let listener = try NWListener(using: parameters)
- let queue = DispatchQueue(label: "com.gutenbergkit.http-server.\(safeName)")
-
- let requiresAuth = requiresAuthentication
- // Falls back to `readTimeout` so consumers that don't distinguish the two
- // keep the prior whole-request behavior.
- let resolvedBodyReadTimeout = bodyReadTimeout ?? readTimeout
- listener.newConnectionHandler = { connection in
- guard connectionCounter.tryIncrement() else {
- Logger.httpServer.warning("Connection limit reached, rejecting connection")
- connection.cancel()
- return
- }
- handleConnection(
- connection, queue: queue, token: token,
- requiresAuthentication: requiresAuth,
- maxRequestBodySize: maxRequestBodySize, readTimeout: readTimeout,
- bodyReadTimeout: resolvedBodyReadTimeout,
- idleTimeout: idleTimeout, cors: cors, tempDirectory: tempDirectory,
- connectionCounter: connectionCounter, connectionTasks: connectionTasks,
- delegate: delegate, handler: handler
- )
- }
-
- // Bridge listener state callbacks to an AsyncStream so we can await readiness.
- // The listener is started synchronously — only the wait is async.
- let (states, statesContinuation) = AsyncStream.makeStream(of: NWListener.State.self)
- listener.stateUpdateHandler = { state in
- statesContinuation.yield(state)
- }
- listener.start(queue: queue)
-
- // Bound the wait for readiness. The listener can sit in a non-terminal
- // state (e.g. `.waiting` when it can't establish an endpoint) indefinitely;
- // since callers await this — the editor load awaits the upload server's
- // bind — an unbounded wait would hang the caller, not just fail the server.
- // Race the readiness wait against a timeout, and tear the listener down on
- // any failure path so its socket isn't leaked. On `.ready` the group
- // returns the server without throwing, so a successfully-started server
- // never has its listener cancelled out from under it.
- do {
- return try await withStartTimeout(startTimeout) {
- for await state in states {
- switch state {
- case .ready:
- listener.stateUpdateHandler = nil
- guard let p = listener.port else {
- throw HTTPServerError.failedToStart
- }
- let server = HTTPServer(listener: listener, port: p.rawValue, queue: queue, token: token, connectionTasks: connectionTasks, cleanupTask: cleanupTask)
- Logger.httpServer.info("HTTP server started on port \(p.rawValue)")
- return server
- case .failed(let error):
- Logger.httpServer.error("Listener failed: \(error)")
- throw HTTPServerError.failedToStart
- case .cancelled:
- throw HTTPServerError.failedToStart
- default:
- continue
- }
- }
- throw HTTPServerError.failedToStart
- }
- } catch {
- // Failure or timeout: the listener may still be started, so cancel it
- // to release the socket. (On success the returned server owns it.)
- listener.cancel()
- throw error
- }
- }
-
- /// Starts a server that serves requests from an ``HTTPRequestHandler`` object
- /// rather than a closure.
- ///
- /// Everything else behaves identically — this forwards to the closure form. Reach
- /// for it when the handler has dependencies to hold: a `struct` conformer stores
- /// them and serves from instance methods, instead of statics threading a context
- /// parameter through every call. See ``HTTPRequestHandler`` for the (short)
- /// lifetime rules.
- public static func start(
- name: String,
- port: UInt16? = nil,
- listenOnAllInterfaces: Bool = false,
- requiresAuthentication: Bool = true,
- maxRequestBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize,
- maxConnections: Int = HTTPServer.defaultMaxConnections,
- readTimeout: Duration = HTTPServer.defaultReadTimeout,
- bodyReadTimeout: Duration? = nil,
- idleTimeout: Duration = HTTPServer.defaultIdleTimeout,
- startTimeout: Duration = HTTPServer.defaultStartTimeout,
- cors: CORSPolicy = .none,
- delegate: HTTPServerDelegate? = nil,
- handler: some HTTPRequestHandler
- ) async throws -> HTTPServer {
- try await start(
- name: name,
- port: port,
- listenOnAllInterfaces: listenOnAllInterfaces,
- requiresAuthentication: requiresAuthentication,
- maxRequestBodySize: maxRequestBodySize,
- maxConnections: maxConnections,
- readTimeout: readTimeout,
- bodyReadTimeout: bodyReadTimeout,
- idleTimeout: idleTimeout,
- startTimeout: startTimeout,
- cors: cors,
- delegate: delegate,
- handler: { await handler.handle($0) }
- )
- }
-
- /// Races `operation` against `timeout`, throwing ``HTTPServerError/startTimeout``
- /// if the timeout wins. Used to bound the wait for the listener to become ready
- /// so a caller — such as the editor load awaiting the upload server's bind —
- /// isn't hung on a listener stuck in a non-terminal state.
- static func withStartTimeout(
- _ timeout: Duration,
- _ operation: @escaping @Sendable () async throws -> T
- ) async throws -> T {
- try await withThrowingTaskGroup(of: T.self) { group in
- group.addTask {
- try await operation()
- }
- group.addTask {
- try await Task.sleep(for: timeout)
- throw HTTPServerError.startTimeout
- }
- let result = try await group.next()!
- group.cancelAll()
- return result
- }
- }
-
- /// Stops the server and releases resources.
- ///
- /// Cancels the listener and all in-flight connection tasks. Handlers that
- /// are currently executing will receive a `CancellationError`.
- public func stop() {
- listener.cancel()
- releaseConnectionHandler()
- connectionTasks.cancelAll()
- Logger.httpServer.info("HTTP server stopped")
- }
-
- deinit {
- listener.cancel()
- releaseConnectionHandler()
- connectionTasks.cancelAll()
- }
-
- /// Drops the connection handler so teardown releases what it captured *here*,
- /// on the caller's thread.
- ///
- /// `newConnectionHandler` retains the request handler, and through it whatever
- /// the caller's closure captured. `cancel()` alone does not drop the block:
- /// Network.framework holds the listener until cancellation completes on its own
- /// queue, so the final release — and therefore the captured object's `deinit` —
- /// lands there rather than wherever `stop()` was called.
- ///
- /// That covers an idle server. A request still in flight holds its own copy of what
- /// the handler captured until that task unwinds, so a server stopped mid-request
- /// releases last on the task's executor no matter what this does.
- ///
- /// Clearing it after `cancel()` rather than before is deliberate: the listener is
- /// already torn down, so there is no window in which it is live but has no handler
- /// to hand a connection to.
- private func releaseConnectionHandler() {
- listener.newConnectionHandler = nil
- }
-
- /// The library's default response for a parse error: the mapped status code
- /// with a plain-text body echoing the RFC reason phrase (e.g. 413 "Content Too
- /// Large"). This is what fatal errors always use, what a recoverable error uses
- /// when no delegate customizes it, and what an ``HTTPServerDelegate`` can
- /// delegate back to for cases it doesn't handle.
- public static func defaultErrorResponse(for error: HTTPRequestParseError) -> HTTPResponse {
- let statusText = String(error.httpStatusText)
- return HTTPResponse(status: error.httpStatus, statusText: statusText, body: Data(statusText.utf8))
- }
-
- // MARK: - Connection Handling
-
- private static func handleConnection(
- _ connection: NWConnection,
- queue: DispatchQueue,
- token: String,
- requiresAuthentication: Bool,
- maxRequestBodySize: Int64,
- readTimeout: Duration,
- bodyReadTimeout: Duration,
- idleTimeout: Duration,
- cors: CORSPolicy,
- tempDirectory: URL,
- connectionCounter: ConnectionCounter,
- connectionTasks: ConnectionTasks,
- delegate: HTTPServerDelegate?,
- handler: @escaping @Sendable (HTTPServer.Request) async -> HTTPResponse
- ) {
- connection.start(queue: queue)
-
- let taskID = UUID()
- let task = Task {
- defer {
- connectionCounter.decrement()
- }
-
- do {
- let parser = HTTPRequestParser(maxBodySize: maxRequestBodySize, tempDirectory: tempDirectory)
- var request: ParsedHTTPRequest!
- let duration = try await ContinuousClock().measure {
- // Phase 1 (pre-body): receive and validate headers, authenticate,
- // and drain any oversized body — all bounded by `readTimeout`. This is
- // the unauthenticated-reachable portion of the request, so it keeps a
- // strict total-duration cap.
- let partial = try await Self.withReadTimeout(readTimeout) { () -> ParsedHTTPRequest in
- // Receive headers only.
- try await Self.receiveUntil(\.hasHeaders, parser: parser, on: connection, idleTimeout: idleTimeout)
-
- // Validate headers (triggers full RFC validation).
- guard let partial = try parser.parseRequest() else {
- throw HTTPServerError.connectionClosed
- }
-
- // Check auth on headers alone, before draining or consuming any
- // body bytes — an unauthenticated client must not be able to make
- // the server read (and discard) an arbitrarily large body, and the
- // handler must never see an unauthenticated request. OPTIONS is
- // exempt because CORS preflight requests never include credentials
- // (Fetch spec §3.3.5).
- if requiresAuthentication && partial.method.uppercased() != "OPTIONS" {
- guard authenticate(partial, token: token) else {
- throw HTTPServerError.authenticationFailed
- }
- }
-
- // Reject auth-exempt OPTIONS that carry a body. Real CORS preflight
- // requests are bodyless; a body on the auth-exempt path would
- // otherwise be read/drained without authentication — and the
- // accepted-body read below is bounded only by the idle timeout.
- if partial.method.uppercased() == "OPTIONS", (parser.expectedBodyLength ?? 0) > 0 {
- throw HTTPServerError.unexpectedBody
- }
-
- // Drain the oversized body before responding so the (authenticated)
- // client receives the 413 instead of a connection reset
- // (RFC 9110 §15.5.14). Still bounded by `readTimeout`.
- if parser.state == .draining {
- try await Self.receiveUntil(\.isComplete, parser: parser, on: connection, idleTimeout: idleTimeout)
- }
-
- return partial
- }
-
- // If the parser detected a recoverable error (e.g. payload too
- // large, drained above), stop reading and let the post-measure
- // branch answer it via the delegate. `request` is the body-less
- // partial; the main handler is never invoked for it.
- if parser.parseError != nil {
- request = partial
- return
- }
-
- // Reject body-bearing methods without Content-Length. We don't support
- // Transfer-Encoding: chunked, so Content-Length is the only way to
- // determine body size.
- let upperMethod = partial.method.uppercased()
- if ["POST", "PUT", "PATCH"].contains(upperMethod) && partial.header("Content-Length") == nil {
- throw HTTPServerError.lengthRequired
- }
-
- // Phase 2 (accepted body): the client is authenticated, so read the body
- // bounded by `bodyReadTimeout` (a generous backstop) plus the per-read
- // `idleTimeout`. A large upload that streams steadily is never failed on
- // total duration — only a genuine stall (idle) or the generous ceiling
- // ends it.
- if !parser.state.isComplete {
- try await Self.withReadTimeout(bodyReadTimeout) {
- try await Self.receiveUntil(\.isComplete, parser: parser, on: connection, idleTimeout: idleTimeout)
- }
- }
-
- guard let complete = try parser.parseRequest(), complete.isComplete else {
- throw HTTPServerError.connectionClosed
- }
- request = complete
- }
-
- // A recoverable parse error (payload too large, drained above): the
- // request was never fully read, so it must not reach the handler.
- // The library owns the response — the delegate customizes the body if
- // it wants, otherwise a correct generic error. `send` stamps CORS.
- let response: HTTPResponse
- if let parseError = parser.parseError {
- response = delegate?.response(forRecoverableParseError: parseError)
- ?? Self.defaultErrorResponse(for: parseError)
- } else if cors == .permissive, request.method.uppercased() == "OPTIONS" {
- // Under a permissive CORS policy the library answers the OPTIONS
- // preflight itself; the send layer stamps the CORS headers.
- response = HTTPResponse(status: 204)
- } else {
- // Run the handler, but race it against the peer closing the
- // connection. Once the request has been fully read, no bytes
- // flow on this connection until the response is sent, so a
- // handler that awaits slow outbound work — the media-upload
- // relay awaiting `POST /wp/v2/media` — leaves the connection
- // idle. If the client (the editor WebView) aborts the upload
- // during that window, nothing here would otherwise notice, and
- // the outbound request would run to completion, creating an
- // orphaned attachment that a retry then duplicates. Watching for
- // the close and cancelling the handler propagates cancellation
- // through structured concurrency to the outbound URLSession
- // task, so a cancelled upload is actually cancelled.
- switch await Self.runHandler(
- handler,
- Request(parsed: request, parseDuration: duration),
- racingCloseOf: connection
- ) {
- case .completed(let handlerResponse):
- response = handlerResponse
- case .clientDisconnected:
- Logger.httpServer.debug("\(request.method) \(request.target) → client disconnected before response; cancelled in-flight handler")
- connection.cancel()
- return
- }
- }
- // The handler type is non-throwing and maps cancellation to a 500,
- // so if the connection task was cancelled while it ran (server stop /
- // editor teardown), honor that here rather than writing a doomed
- // response: propagate so the outer handler just closes the connection.
- try Task.checkCancellation()
- await send(response, on: connection, cors: cors)
- let (sec, atto) = duration.components
- let ms = Double(sec) * 1000.0 + Double(atto) / 1_000_000_000_000_000.0
- Logger.httpServer.debug("\(request.method) \(request.target) → \(response.status) (\(String(format: "%.1f", ms))ms)")
- } catch HTTPServerError.authenticationFailed {
- await send(HTTPResponse(status: 407, headers: [("Content-Type", "text/plain"), ("Proxy-Authenticate", "Bearer")]), on: connection, cors: cors)
- } catch HTTPServerError.lengthRequired {
- await send(HTTPResponse(status: 411, statusText: "Length Required", body: Data("Length Required".utf8)), on: connection, cors: cors)
- } catch HTTPServerError.unexpectedBody {
- Logger.httpServer.warning("Rejected auth-exempt request carrying a body")
- await send(HTTPResponse(status: 400, statusText: "Bad Request", body: Data("Unexpected request body".utf8)), on: connection, cors: cors)
- } catch is CancellationError {
- Logger.httpServer.debug("Connection cancelled during shutdown")
- connection.cancel()
- } catch HTTPServerError.readTimeout {
- Logger.httpServer.warning("Read timeout, closing connection")
- await send(HTTPResponse(status: 408, statusText: "Request Timeout", body: Data("Request Timeout".utf8)), on: connection, cors: cors)
- } catch let error as HTTPRequestParseError {
- // Fatal parse error (malformed framing, smuggling-relevant, etc.):
- // always answered by the library, never routed to the delegate.
- Logger.httpServer.error("Parse error: \(error)")
- await send(Self.defaultErrorResponse(for: error), on: connection, cors: cors)
- } catch {
- Logger.httpServer.error("Unexpected error: \(error)")
- await send(HTTPResponse(status: 400, statusText: "Bad Request", body: Data("Malformed HTTP request".utf8)), on: connection, cors: cors)
- }
- }
- connectionTasks.track(taskID, task)
- }
-
- /// Runs `operation` under a total-duration timeout, racing it against a sleep
- /// task. Used to bound one phase of the request read (pre-body vs. accepted
- /// body). The per-read `idleTimeout` inside `operation` still applies
- /// independently, and cancellation of the enclosing task cancels both children.
- private static func withReadTimeout(
- _ timeout: Duration,
- _ operation: @escaping @Sendable () async throws -> T
- ) async throws -> T {
- try await withThrowingTaskGroup(of: T.self) { group in
- group.addTask {
- try await operation()
- }
- group.addTask {
- try await Task.sleep(for: timeout)
- throw HTTPServerError.readTimeout
- }
- let result = try await group.next()!
- group.cancelAll()
- return result
- }
- }
-
- /// Feeds data from the connection into the parser until the given state
- /// predicate is satisfied or the connection closes.
- ///
- /// Each individual read is guarded by `idleTimeout` to prevent slow-loris
- /// attacks where an attacker drip-feeds one byte at a time to hold a
- /// connection slot open.
- private static func receiveUntil(
- _ condition: KeyPath,
- parser: HTTPRequestParser,
- on connection: NWConnection,
- idleTimeout: Duration
- ) async throws {
- while !parser.state[keyPath: condition] {
- let data = try await receiveWithIdleTimeout(on: connection, timeout: idleTimeout)
-
- guard let data else {
- throw HTTPServerError.connectionClosed
- }
-
- parser.append(data)
- }
- }
-
- /// Reads a chunk of data from the connection, enforcing an idle timeout.
- ///
- /// - Returns: The received data, or `nil` if the connection completed with no more data.
- /// - Throws: ``HTTPServerError/readTimeout`` if no data arrives within the timeout.
- private static func receiveWithIdleTimeout(on connection: NWConnection, timeout: Duration) async throws -> Data? {
- try await withThrowingTaskGroup(of: Data?.self) { group in
- group.addTask {
- try await receive(on: connection)
- }
- group.addTask {
- try await Task.sleep(for: timeout)
- throw HTTPServerError.readTimeout
- }
- let result = try await group.next()!
- group.cancelAll()
- return result
- }
- }
-
- /// Reads a chunk of data from the connection.
- ///
- /// - Returns: The received data, or `nil` if the connection completed with no more data.
- ///
- /// On a spurious wake (no content, no error, not complete), re-issues the
- /// receive once. Uses a class-based flag to guarantee the continuation is
- /// resumed exactly once even if `onCancel` fires concurrently with the
- /// receive callback.
- private static func receive(on connection: NWConnection) async throws -> Data? {
- try await withTaskCancellationHandler {
- try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
- receiveOnce(on: connection, retryOnSpuriousWake: true, continuation: continuation)
- }
- } onCancel: {
- connection.cancel()
- }
- }
-
- /// Issues a single `connection.receive` and resumes the continuation.
- ///
- /// If the callback delivers a spurious wake (no data, no error, not
- /// complete) and `retryOnSpuriousWake` is true, re-issues the receive once.
- /// On the second spurious wake, treats it as connection closed.
- ///
- /// The `OnceGuard` ensures the continuation is resumed at most once. If
- /// `onCancel` fires and cancels the connection, NWConnection delivers an
- /// error callback. Without the guard, that error callback could race with a
- /// legitimate resume from the data path. The guard makes the first resume
- /// win and silently drops any subsequent attempts.
- private static func receiveOnce(
- on connection: NWConnection,
- retryOnSpuriousWake: Bool,
- continuation: CheckedContinuation
- ) {
- let guard_ = OnceGuard()
- connection.receive(minimumIncompleteLength: 1, maximumLength: readChunkSize) { content, _, isComplete, error in
- if let error {
- if guard_.claim() { continuation.resume(throwing: HTTPServerError.networkError(error)) }
- } else if let content, !content.isEmpty {
- if guard_.claim() { continuation.resume(returning: content) }
- } else if isComplete {
- if guard_.claim() { continuation.resume(returning: nil) }
- } else if retryOnSpuriousWake {
- // Spurious wake — re-issue once. The recursive call creates a
- // fresh OnceGuard, which is correct: the old callback from this
- // receive won't fire again, so the old guard is inert.
- receiveOnce(on: connection, retryOnSpuriousWake: false, continuation: continuation)
- } else {
- if guard_.claim() { continuation.resume(returning: nil) }
- }
- }
- }
-
- /// Thread-safe flag ensuring a continuation is resumed exactly once.
- private final class OnceGuard: @unchecked Sendable {
- private let _claimed = NSLock()
- private var _value = false
- func claim() -> Bool {
- _claimed.lock()
- defer { _claimed.unlock() }
- if _value { return false }
- _value = true
- return true
- }
- }
-
- /// The result of racing a request handler against the connection's peer
- /// closing it. See ``runHandler(_:_:racingCloseOf:)``.
- private enum HandlerOutcome: Sendable {
- case completed(HTTPResponse)
- case clientDisconnected
- }
-
- /// Runs `handler`, racing it against the connection's peer closing it.
- ///
- /// Between the end of the request and the start of the response a
- /// well-behaved HTTP/1.1 client sends nothing, so a receive posted now can
- /// only complete when the peer closes the connection (EOF) or it fails —
- /// i.e. the client went away (an aborted `fetch`). If that wins the race, the
- /// handler task is cancelled, which propagates through structured concurrency
- /// to any outbound work the handler is awaiting, and the caller skips the
- /// (doomed) send. If the handler wins, the watcher is cancelled *without*
- /// cancelling the connection, so the response can still be sent.
- ///
- /// A read EOF can't distinguish a full close from a client *write*-half-close
- /// (`shutdown(SHUT_WR)` after the request, read half kept open for the
- /// response), so both are deliberately treated as an abort. That's safe here
- /// because the only client is the editor WebView's `fetch`, which never
- /// half-closes and fully closes on abort; serving a half-closer instead would
- /// forfeit the prompt cancellation this exists for — the two are only
- /// distinguishable by attempting the write, by which point an aborted upload
- /// has already run. A regression test pins this.
- private static func runHandler(
- _ handler: @escaping @Sendable (HTTPServer.Request) async -> HTTPResponse,
- _ request: HTTPServer.Request,
- racingCloseOf connection: NWConnection
- ) async -> HandlerOutcome {
- await withTaskGroup(of: HandlerOutcome.self) { group in
- group.addTask {
- .completed(await handler(request))
- }
- group.addTask {
- await waitForConnectionClose(on: connection)
- return .clientDisconnected
- }
- let outcome = await group.next()!
- group.cancelAll()
- return outcome
- }
- }
-
- /// Suspends until the connection's peer closes its send half (EOF) — a full
- /// close or a write-half-close alike — or it fails, by posting a receive
- /// whose bytes are discarded (it never feeds the parser).
- /// A well-behaved client sends nothing before the response, so in the common
- /// case the receive simply stays pending until the peer closes.
- ///
- /// If the surrounding task is cancelled first (the handler finished), this
- /// returns *without* cancelling the connection, so the caller can still use
- /// it to send the response.
- private static func waitForConnectionClose(on connection: NWConnection) async {
- let watcher = ConnectionCloseWatcher()
- await withTaskCancellationHandler {
- await withCheckedContinuation { (continuation: CheckedContinuation) in
- guard watcher.store(continuation) else { return }
- watchForClose(on: connection, watcher: watcher)
- }
- } onCancel: {
- watcher.cancel()
- }
- }
-
- /// Posts a receive that wakes the watcher when the peer closes the
- /// connection. Re-issues on a spurious wake or on unexpected pre-response
- /// bytes (both are discarded); a normal idle connection never invokes the
- /// callback until the peer actually closes.
- private static func watchForClose(on connection: NWConnection, watcher: ConnectionCloseWatcher) {
- connection.receive(minimumIncompleteLength: 1, maximumLength: readChunkSize) { _, _, isComplete, error in
- if error != nil || isComplete {
- watcher.resume()
- } else {
- watchForClose(on: connection, watcher: watcher)
- }
- }
- }
-
- /// Resumes ``waitForConnectionClose``'s continuation exactly once, whether
- /// the wake comes from the peer closing the connection or from the
- /// surrounding task being cancelled. Cancellation never touches the
- /// connection itself.
- private final class ConnectionCloseWatcher: @unchecked Sendable {
- private let lock = NSLock()
- private var continuation: CheckedContinuation?
- private var resumed = false
- private var cancelled = false
-
- /// Stores the continuation. Returns `false` — and resumes immediately —
- /// if cancellation already arrived, so the caller skips posting a receive.
- func store(_ continuation: CheckedContinuation) -> Bool {
- lock.lock()
- if cancelled {
- resumed = true
- lock.unlock()
- continuation.resume()
- return false
- }
- self.continuation = continuation
- lock.unlock()
- return true
- }
-
- /// The peer closed the connection.
- func resume() {
- lock.lock()
- guard !resumed, let continuation else { lock.unlock(); return }
- resumed = true
- self.continuation = nil
- lock.unlock()
- continuation.resume()
- }
-
- /// The surrounding task was cancelled (the handler finished first).
- func cancel() {
- lock.lock()
- cancelled = true
- guard !resumed, let continuation else { lock.unlock(); return }
- resumed = true
- self.continuation = nil
- lock.unlock()
- continuation.resume()
- }
- }
-
- /// Sends a response on the connection and then closes it.
- private static func send(_ response: HTTPResponse, on connection: NWConnection, cors: CORSPolicy) async {
- let decorated = response.addingHeadersIfAbsent(cors.responseHeaders)
- await withCheckedContinuation { (continuation: CheckedContinuation) in
- connection.send(content: decorated.serialized(), completion: .contentProcessed { _ in
- connection.cancel()
- continuation.resume()
- })
- }
- }
-
- // MARK: - Authentication
-
- /// Validates the proxy bearer token from the request.
- ///
- /// Accepts the token in either:
- /// - `Proxy-Authorization` — the standard HTTP header for proxy credentials
- /// (RFC 9110 §11.7.1), usable from native HTTP clients.
- /// - `Relay-Authorization` — a non-forbidden alternative usable from
- /// browser `fetch()`, where `Proxy-*` headers are silently stripped
- /// (Fetch spec §2.2.2).
- ///
- /// Both headers keep the client's `Authorization` header available for
- /// upstream credentials.
- private static func authenticate(_ request: ParsedHTTPRequest, token: String) -> Bool {
- let proxyAuth = request.header("Proxy-Authorization")
- ?? request.header("Relay-Authorization")
- guard let proxyAuth else {
- return false
- }
-
- let prefix = "Bearer "
- guard proxyAuth.prefix(prefix.count).caseInsensitiveCompare(prefix) == .orderedSame else {
- return false
- }
-
- let provided = String(proxyAuth.dropFirst(prefix.count))
- return constantTimeEqual(provided, token)
- }
-
- /// Compares two strings in constant time to prevent timing attacks.
- ///
- /// Always iterates over the expected token (b) regardless of the input
- /// length, so timing reveals neither whether lengths match nor how many
- /// bytes are correct. When lengths differ, b is compared against itself
- /// to keep the work constant.
- ///
- /// **Do not "simplify" this to an early-return on length mismatch.**
- /// An early return would let an attacker measure response time to discover
- /// the expected token length, even though the token length is currently
- /// fixed at 64 hex characters. This implementation is intentionally
- /// branch-free in the hot path to avoid leaking any information.
- private static func constantTimeEqual(_ a: String, _ b: String) -> Bool {
- let aBytes = Array(a.utf8)
- let bBytes = Array(b.utf8)
- var result: UInt8 = aBytes.count == bBytes.count ? 0 : 1
- let comparand = aBytes.count == bBytes.count ? aBytes : bBytes
- for i in bBytes.indices {
- result |= comparand[i] ^ bBytes[i]
- }
- return result == 0
- }
-
- /// Strips characters from `name` that are not letters, digits, `.`, `-`, or `_`.
- ///
- /// The server `name` is embedded in filesystem paths (temp directory) and
- /// dispatch queue labels. Allowing arbitrary characters (e.g. `../`) would
- /// enable path traversal. This filter reduces the name to a safe subset.
- private static func sanitizeName(_ name: String) -> String {
- let sanitized = String(name.unicodeScalars.filter {
- CharacterSet.alphanumerics.contains($0) || $0 == "." || $0 == "-" || $0 == "_"
- })
- precondition(!sanitized.isEmpty, "Server name must contain at least one alphanumeric character, dot, hyphen, or underscore")
- return sanitized
- }
-
- private static func generateToken() -> String {
- var bytes = [UInt8](repeating: 0, count: 32)
- let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
- precondition(status == noErr, "Failed to generate random token")
- return bytes.map { String(format: "%02x", $0) }.joined()
- }
-
- /// Removes orphaned temp files left by a previous crash.
- ///
- /// The parser creates temp files in a server-specific subdirectory under the
- /// system temp directory (e.g., `GutenbergKitHTTP-media-proxy/`). Under normal
- /// operation, `Buffer`/`TempFileOwner` delete them via ARC. After a crash these
- /// files survive — this method cleans them up on the next server start.
- ///
- /// Files currently backing an in-flight request are registered in
- /// ``ActiveTempFiles`` and skipped, so a server instance that shares a
- /// directory with a concurrently-running instance of the same name (e.g. two
- /// editors open at once, or one being torn down as another starts) does not
- /// delete the other's live buffers. Files not in the registry have no live
- /// owner in this process — they are crash orphans and are removed. The sweep
- /// runs detached from `start()` (see `cleanupTask`), off the caller's startup
- /// path; the registry keeps it safe regardless of when it runs.
- static func cleanOrphanedTempFiles(in directory: URL) {
- guard let contents = try? FileManager.default.contentsOfDirectory(
- at: directory, includingPropertiesForKeys: nil
- ) else { return }
- for url in contents where !ActiveTempFiles.contains(url.lastPathComponent) {
- try? FileManager.default.removeItem(at: url)
- }
- }
-}
-
-/// Thread-safe tracker for in-flight connection tasks, enabling graceful shutdown.
-///
-/// Completed tasks are intentionally not removed. Entries are tiny (UUID + Task
-/// reference) and accumulate only for the server's lifetime. Removing on completion
-/// would require a `defer` inside each Task, but if the task finishes before
-/// `track()` is called the `remove()` is a no-op — leaving a stale entry anyway.
-/// Skipping removal avoids that race entirely. `cancelAll()` clears everything
-/// on `stop()`.
-///
-/// All mutable state is guarded by `lock`.
-final class ConnectionTasks: @unchecked Sendable {
- private let lock = NSLock()
- private var tasks: [UUID: Task] = [:]
-
- func track(_ id: UUID, _ task: Task) {
- lock.withLock {
- tasks[id] = task
- }
- }
-
- func cancelAll() {
- lock.withLock {
- for task in tasks.values {
- task.cancel()
- }
- tasks.removeAll()
- }
- }
-}
-
-/// Thread-safe counter for tracking active connections.
-final class ConnectionCounter: @unchecked Sendable {
- private let lock = NSLock()
- private let limit: Int
- private var _count: Int = 0
-
- init(limit: Int) {
- self.limit = limit
- }
-
- /// Attempts to increment the counter. Returns `true` if the connection is allowed.
- func tryIncrement() -> Bool {
- lock.withLock {
- guard _count < limit else { return false }
- _count += 1
- return true
- }
- }
-
- /// Decrements the counter when a connection completes.
- func decrement() {
- lock.withLock {
- _count -= 1
- }
- }
-}
-
-// MARK: - Logger
-
-extension Logger {
- static let httpServer = Logger(subsystem: "com.gutenbergkit.http", category: "server")
-}
-
-#endif // canImport(Network)
diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServerDelegate.swift b/ios/Sources/GutenbergKitHTTP/HTTPServerDelegate.swift
deleted file mode 100644
index 281533746..000000000
--- a/ios/Sources/GutenbergKitHTTP/HTTPServerDelegate.swift
+++ /dev/null
@@ -1,41 +0,0 @@
-#if canImport(Network)
-
-import Foundation
-
-/// Customization points for an ``HTTPServer``, beyond its main request handler.
-///
-/// Every method has a default implementation, so a conformer implements only the
-/// behavior it wants to change. A server started without a delegate — or whose
-/// delegate leaves a method defaulted — uses the library's built-in behavior.
-/// New customization points are added here as new defaulted methods, so
-/// ``HTTPServer/start(name:port:listenOnAllInterfaces:requiresAuthentication:maxRequestBodySize:maxConnections:readTimeout:bodyReadTimeout:idleTimeout:startTimeout:cors:delegate:handler:)``
-/// never grows another parameter for them.
-///
-/// The server **retains** its delegate for its lifetime. Because the delegate is
-/// injected at `start(...)` rather than assigned as a back-reference, this does
-/// not create a reference cycle unless the delegate itself strongly holds the
-/// server — keep the delegate a leaf, or break the cycle yourself.
-public protocol HTTPServerDelegate: AnyObject, Sendable {
- /// The response to send for a *recoverable* parse error — one where the
- /// request line and headers are well-formed but the request can't be accepted
- /// in full (today only an over-limit body, HTTP 413). The body was drained and
- /// is unavailable, and the main request handler is intentionally **not**
- /// invoked, so a handler can never mistake a rejected request for a normal one.
- ///
- /// The default returns a generic status + reason-phrase response
- /// (``HTTPServer/defaultErrorResponse(for:)``). Override to supply a
- /// consumer-specific body — e.g. a JSON error the client can parse. The server
- /// still stamps CORS headers on whatever you return.
- ///
- /// Fatal parse errors (malformed framing, header smuggling, etc.) are always
- /// answered by the library and never routed here.
- func response(forRecoverableParseError error: HTTPRequestParseError) -> HTTPResponse
-}
-
-public extension HTTPServerDelegate {
- func response(forRecoverableParseError error: HTTPRequestParseError) -> HTTPResponse {
- HTTPServer.defaultErrorResponse(for: error)
- }
-}
-
-#endif // canImport(Network)
diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServerError.swift b/ios/Sources/GutenbergKitHTTP/HTTPServerError.swift
deleted file mode 100644
index 1b24009a5..000000000
--- a/ios/Sources/GutenbergKitHTTP/HTTPServerError.swift
+++ /dev/null
@@ -1,42 +0,0 @@
-#if canImport(Network)
-
-import Foundation
-import Network
-
-/// Errors thrown by ``HTTPServer``.
-public enum HTTPServerError: Error, LocalizedError, Sendable {
- /// The server failed to bind to the requested port.
- case failedToStart
- /// The listener did not become ready within the start timeout (e.g. it was
- /// stuck in `.waiting`). Bounds the bind wait so a caller — such as the
- /// editor load — isn't hung indefinitely on a listener that never binds.
- case startTimeout
- /// The connection closed before a complete request was received.
- case connectionClosed
- /// The read timeout expired before a complete request was received.
- case readTimeout
- /// The request failed authentication (checked after headers, before body).
- case authenticationFailed
- /// The request method requires a Content-Length header but none was provided.
- case lengthRequired
- /// An auth-exempt request (OPTIONS) carried a body. CORS preflights are
- /// bodyless, so a body on the auth-exempt path is rejected rather than read.
- case unexpectedBody
- /// A network-level error occurred on the connection.
- case networkError(NWError)
-
- public var errorDescription: String? {
- switch self {
- case .failedToStart: "Failed to start HTTP server"
- case .startTimeout: "HTTP server listener did not become ready within the start timeout"
- case .connectionClosed: "Connection closed before request was complete"
- case .readTimeout: "Read timeout expired before request was complete"
- case .authenticationFailed: "Request failed authentication"
- case .lengthRequired: "Content-Length header is required for this method"
- case .unexpectedBody: "Request method must not carry a body"
- case .networkError(let error): "Network error: \(error.localizedDescription)"
- }
- }
-}
-
-#endif // canImport(Network)
diff --git a/ios/Sources/GutenbergKitHTTP/HeaderValue.swift b/ios/Sources/GutenbergKitHTTP/HeaderValue.swift
deleted file mode 100644
index 4b9058e02..000000000
--- a/ios/Sources/GutenbergKitHTTP/HeaderValue.swift
+++ /dev/null
@@ -1,121 +0,0 @@
-import Foundation
-
-/// Utilities for parsing structured HTTP header values (RFC 9110 §5.6).
-///
-/// HTTP headers like `Content-Type` and `Content-Disposition` carry parameters
-/// in `key=value` or `key="value"` form. This enum provides a shared
-/// implementation for extracting those parameters while correctly handling
-/// quoted strings and backslash escapes per RFC 2045 §5.1.
-enum HeaderValue {
-
- /// Extracts a parameter value from a header value string.
- ///
- /// Searches for `name=` while skipping occurrences that fall inside
- /// quoted strings, then extracts the value — handling both quoted
- /// (with backslash escapes per RFC 2045 §5.1) and unquoted forms.
- ///
- /// ```swift
- /// // Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
- /// HeaderValue.extractParameter("boundary", from: contentType)
- ///
- /// // Content-Disposition: form-data; name="file"; filename="photo.jpg"
- /// HeaderValue.extractParameter("filename", from: disposition)
- /// ```
- ///
- /// - Parameters:
- /// - name: The parameter name to search for (case-insensitive).
- /// - headerValue: The full header value string to search.
- /// - Returns: The extracted parameter value, or `nil` if not found.
- static func extractParameter(_ name: String, from headerValue: String) -> String? {
- let search = "\(name)="
- var searchStart = headerValue.startIndex
-
- while searchStart < headerValue.endIndex {
- guard let paramRange = headerValue.range(
- of: search,
- options: .caseInsensitive,
- range: searchStart.. headerValue.startIndex {
- let preceding = headerValue[headerValue.index(before: paramRange.lowerBound)]
- if preceding != ";" && preceding != " " && preceding != "\t" {
- searchStart = paramRange.upperBound
- continue
- }
- }
-
- let afterEquals = headerValue[paramRange.upperBound...]
-
- if afterEquals.hasPrefix("\"") {
- return extractQuotedValue(afterEquals)
- } else {
- let endIndex = afterEquals.firstIndex(of: ";") ?? afterEquals.endIndex
- return String(afterEquals[.. String {
- let valueStart = text.index(after: text.startIndex)
- var index = valueStart
- var result = ""
-
- while index < text.endIndex {
- let char = text[index]
- if char == "\\" {
- let next = text.index(after: index)
- if next < text.endIndex {
- result.append(text[next])
- index = text.index(after: next)
- } else {
- break
- }
- } else if char == "\"" {
- break
- } else {
- result.append(char)
- index = text.index(after: index)
- }
- }
- return result
- }
-
- /// Returns whether the given position in the string falls inside a quoted string.
- ///
- /// Scans from the start, tracking quote open/close state while respecting
- /// backslash escapes.
- private static func isInsideQuotedString(_ string: String, position: String.Index) -> Bool {
- var inQuote = false
- var index = string.startIndex
- while index < position {
- let char = string[index]
- if inQuote && char == "\\" {
- // Skip escaped character
- index = string.index(after: index)
- if index < position {
- index = string.index(after: index)
- }
- continue
- }
- if char == "\"" {
- inQuote.toggle()
- }
- index = string.index(after: index)
- }
- return inQuote
- }
-}
diff --git a/ios/Sources/GutenbergKitHTTP/MultipartPart.swift b/ios/Sources/GutenbergKitHTTP/MultipartPart.swift
deleted file mode 100644
index cca977470..000000000
--- a/ios/Sources/GutenbergKitHTTP/MultipartPart.swift
+++ /dev/null
@@ -1,398 +0,0 @@
-import Foundation
-
-/// A single part from a `multipart/form-data` body, per RFC 7578.
-///
-/// Each part represents one form field or file upload, with its own
-/// Content-Disposition parameters and optional Content-Type.
-///
-/// Part bodies are represented as lightweight references (byte ranges)
-/// back to the original request body. No part data is copied during parsing;
-/// bytes are only read when ``body`` is accessed via ``RequestBody/makeInputStream()``.
-///
-/// ```swift
-/// let request = try parser.parseRequest()
-/// for part in try request?.multipartParts() ?? [] {
-/// print(part.name, part.filename, part.contentType)
-/// }
-/// ```
-public struct MultipartPart: Sendable, Equatable {
- /// The field name from the `Content-Disposition: form-data; name="..."` parameter.
- public let name: String
- /// The filename, if present, from the `Content-Disposition: form-data; filename="..."` parameter.
- public let filename: String?
- /// The `Content-Type` of this part, or `"text/plain"` if not specified (RFC 7578 §4.4).
- public let contentType: String
- /// The part's body content, backed by a reference to the original request body.
- public let body: RequestBody
-}
-
-/// Errors thrown when parsing a multipart/form-data body fails.
-public enum MultipartParseError: Error, Sendable, Equatable, LocalizedError {
- /// The Content-Type is not `multipart/form-data` or is missing the `boundary` parameter.
- case notMultipartFormData
- /// The body is missing or the request is incomplete.
- case missingBody
- /// A part is missing the required `Content-Disposition: form-data` header.
- case missingContentDisposition
- /// A part's `Content-Disposition` header is missing the required `name` parameter.
- case missingNameParameter
- /// The multipart body structure is malformed (e.g., missing closing boundary).
- case malformedBody
- /// The multipart body contains more than 100 parts.
- case tooManyParts
-
- public var errorDescription: String? {
- switch self {
- case .notMultipartFormData:
- return "The Content-Type is not multipart/form-data or is missing the boundary parameter."
- case .missingBody:
- return "The request body is missing or the request is incomplete."
- case .missingContentDisposition:
- return "A multipart part is missing the required Content-Disposition header."
- case .missingNameParameter:
- return "A multipart part's Content-Disposition header is missing the required name parameter."
- case .malformedBody:
- return "The multipart body is malformed."
- case .tooManyParts:
- return "The multipart body contains more than 100 parts."
- }
- }
-}
-
-// MARK: - Parsing
-
-extension MultipartPart {
-
- private static let scanChunkSize = 65_536
-
- /// Parses an in-memory `multipart/form-data` body into its constituent parts.
- ///
- /// Scans the body data to locate part boundaries and extract headers, but does
- /// not copy part body bytes. Each part's ``body`` is a lightweight reference
- /// (offset + length) back to the source `RequestBody`.
- ///
- /// - Parameters:
- /// - source: The original request body to reference for part content.
- /// - bodyData: The raw body bytes (read once for scanning, then released by the caller).
- /// - bodyFileOffset: The byte offset of `bodyData` within `source`'s backing file
- /// (0 for data-backed bodies).
- /// - boundary: The boundary string from the Content-Type header.
- /// - Returns: An array of parsed parts with lazy body references.
- /// - Throws: ``MultipartParseError`` if the body is malformed.
- static func parse(
- source: RequestBody,
- bodyData: Data,
- bodyFileOffset: UInt64,
- boundary: String
- ) throws -> [MultipartPart] {
- let delimiter = Data("--\(boundary)".utf8)
- let closeDelimiter = Data("--\(boundary)--".utf8)
- let crlf = Data("\r\n".utf8)
- let crlfcrlf = Data("\r\n\r\n".utf8)
-
- guard let firstRange = bodyData.range(of: delimiter) else {
- throw MultipartParseError.malformedBody
- }
-
- var parts: [MultipartPart] = []
- var searchStart = firstRange.upperBound
-
- while searchStart < bodyData.endIndex {
- // RFC 2046 §5.1.1: skip optional transport padding (LWSP) after the boundary.
- // delimiter = CRLF "--" boundary *(SP / HTAB) CRLF
- while searchStart < bodyData.endIndex &&
- (bodyData[searchStart] == UInt8(ascii: " ") || bodyData[searchStart] == UInt8(ascii: "\t")) {
- searchStart = bodyData.index(after: searchStart)
- }
-
- // Skip the CRLF after the delimiter line
- if bodyData[searchStart...].starts(with: crlf) {
- searchStart = bodyData.index(searchStart, offsetBy: crlf.count)
- }
-
- let remaining = bodyData[searchStart...]
- if remaining.isEmpty {
- break
- }
-
- // Find the header/body separator within this part
- guard let headerEnd = bodyData[searchStart...].range(of: crlfcrlf) else {
- throw MultipartParseError.malformedBody
- }
-
- let headerData = bodyData[searchStart..= minBodyEnd {
- let beforeDelimiter = bodyData[bodyData.index(partBodyEnd, offsetBy: -crlf.count).. 100 {
- throw MultipartParseError.tooManyParts
- }
-
- // Check if the next delimiter is the closing one
- if bodyData[nextDelimiter.lowerBound...].starts(with: closeDelimiter) {
- break
- }
-
- searchStart = nextDelimiter.upperBound
- }
-
- return parts
- }
-
- /// Parses a file-backed `multipart/form-data` body using chunked scanning.
- ///
- /// Reads the file in fixed-size chunks to find boundary offsets, keeping memory
- /// usage at O(chunk_size) regardless of body size. Part bodies are file-slice
- /// references, not copies.
- ///
- /// - Parameters:
- /// - source: The file-backed request body.
- /// - boundary: The boundary string from the Content-Type header.
- /// - Returns: An array of parsed parts with lazy body references.
- /// - Throws: ``MultipartParseError`` if the body is malformed.
- static func parseChunked(
- source: RequestBody,
- boundary: String
- ) throws -> [MultipartPart] {
- guard let fileURL = source.fileURL else {
- throw MultipartParseError.malformedBody
- }
-
- let delimiter = Data("--\(boundary)".utf8)
- let crlfcrlf = Data("\r\n\r\n".utf8)
-
- let bodyStart = source.fileOffset
- let bodyLength = UInt64(source.count)
- let bodyEnd = bodyStart + bodyLength
-
- return try FileHandle.withReadHandle(forUrl: fileURL) { fileHandle in
- // Phase 1: Scan for all boundary delimiter offsets using chunked reads.
- // An overlap region (delimiter.count - 1 bytes) is carried between chunks
- // so boundaries split across chunk boundaries are still found.
- let overlapSize = delimiter.count - 1
- var delimiterOffsets: [UInt64] = []
- var position = bodyStart
- var carryOver = Data()
-
- while position < bodyEnd {
- let readSize = min(UInt64(scanChunkSize), bodyEnd - position)
- try fileHandle.seek(toOffset: position)
- guard let chunk = try fileHandle.read(upToCount: Int(readSize)),
- !chunk.isEmpty else {
- break
- }
-
- let searchBuffer = carryOver.isEmpty ? chunk : carryOver + chunk
-
- var searchOffset = searchBuffer.startIndex
- while let range = searchBuffer.range(of: delimiter, in: searchOffset..= bodyStart && absoluteOffset + UInt64(delimiter.count) <= bodyEnd {
- delimiterOffsets.append(absoluteOffset)
- }
- searchOffset = searchBuffer.index(after: range.lowerBound)
- }
-
- if chunk.count > overlapSize {
- carryOver = chunk.suffix(overlapSize)
- } else {
- carryOver = chunk
- }
- position += UInt64(chunk.count)
- }
-
- guard !delimiterOffsets.isEmpty else {
- throw MultipartParseError.malformedBody
- }
-
- // Phase 2: Extract parts from consecutive delimiter pairs.
- var parts: [MultipartPart] = []
- let maxPartHeaderSize: UInt64 = 8192
-
- for i in 0..= partBodyStart + 2 {
- try fileHandle.seek(toOffset: nextDelimStart - 2)
- if let peek = try fileHandle.read(upToCount: 2),
- peek.count == 2,
- peek[peek.startIndex] == 0x0D && peek[peek.startIndex + 1] == 0x0A {
- partBodyEnd = nextDelimStart - 2
- }
- }
-
- let partBodyLength = Int(partBodyEnd - partBodyStart)
- let partBody = RequestBody(
- fileURL: fileURL,
- offset: partBodyStart,
- length: max(0, partBodyLength),
- owner: source.fileOwner
- )
-
- let part = try parsePartHeaders(headerData: headerData, body: partBody)
- parts.append(part)
-
- if parts.count > 100 {
- throw MultipartParseError.tooManyParts
- }
- }
-
- guard !parts.isEmpty else {
- throw MultipartParseError.malformedBody
- }
-
- return parts
- }
- }
-
- /// Creates a `RequestBody` for a part without copying bytes.
- ///
- /// For file-backed sources, returns a file-slice reference. For data-backed
- /// sources, returns a Data slice (which shares storage via copy-on-write).
- private static func makePartBody(
- source: RequestBody,
- bodyData: Data,
- partOffset: Int,
- partLength: Int,
- bodyFileOffset: UInt64
- ) -> RequestBody {
- switch source.storage {
- case .file(let url), .fileSlice(let url, _, _):
- return RequestBody(
- fileURL: url,
- offset: bodyFileOffset + UInt64(partOffset),
- length: partLength,
- owner: source.fileOwner
- )
- case .data:
- let start = bodyData.startIndex + partOffset
- let end = start + partLength
- return RequestBody(data: bodyData[start.. MultipartPart {
- guard let headerString = String(data: headerData, encoding: .utf8) else {
- throw MultipartParseError.missingContentDisposition
- }
-
- let lines = headerString.components(separatedBy: "\r\n")
-
- var contentDisposition: String?
- var contentType: String?
-
- for line in lines where !line.isEmpty {
- guard let colonIndex = line.firstIndex(of: ":") else { continue }
- let key = line[line.startIndex.. String? {
- let lowered = name.lowercased()
- return headers.first(where: { $0.key.lowercased() == lowered })?.value
- }
-
- /// Parses the body as `multipart/form-data` and returns the individual parts.
- ///
- /// Extracts the boundary from the `Content-Type` header automatically.
- /// Part bodies are lazy references back to the original request body — no
- /// part data is copied during parsing. Bytes are only read when a part's
- /// body is accessed via ``RequestBody/makeInputStream()``.
- ///
- /// - Returns: The parsed parts.
- /// - Throws: ``MultipartParseError`` if the Content-Type is not `multipart/form-data`,
- /// the body is missing, or the multipart structure is malformed.
- public func multipartParts() throws -> [MultipartPart] {
- guard let contentType = header("Content-Type"),
- let boundary = Self.extractBoundary(from: contentType) else {
- throw MultipartParseError.notMultipartFormData
- }
-
- guard let body else {
- throw MultipartParseError.missingBody
- }
-
- if let data = body.inMemoryData {
- // In-memory: scan the data directly (already in memory, no extra allocation).
- return try MultipartPart.parse(
- source: body,
- bodyData: data,
- bodyFileOffset: 0,
- boundary: boundary
- )
- } else {
- // File-backed: scan in fixed-size chunks to avoid loading the entire
- // body into memory. Memory usage is O(chunk_size) regardless of body size.
- return try MultipartPart.parseChunked(source: body, boundary: boundary)
- }
- }
-
- /// Extracts the boundary parameter from a `multipart/form-data` Content-Type value.
- ///
- /// Uses ``HeaderValue/extractParameter(_:from:)`` for the actual extraction,
- /// then validates the result against RFC 2046 §5.1.1 boundary constraints.
- private static func extractBoundary(from contentType: String) -> String? {
- guard contentType.lowercased().hasPrefix("multipart/form-data") else {
- return nil
- }
-
- guard let boundary = HeaderValue.extractParameter("boundary", from: contentType) else {
- return nil
- }
-
- guard !boundary.isEmpty, boundary.count <= 70 else { return nil }
- // RFC 2046 §5.1.1: boundary characters must be from the bchars set.
- guard boundary.allSatisfy({ isBoundaryChar($0) }) else { return nil }
- // RFC 2046 §5.1.1: space cannot be the last character of a boundary.
- guard !boundary.hasSuffix(" ") else { return nil }
- return boundary
- }
-
- /// Returns whether a character is valid in a MIME boundary (RFC 2046 §5.1.1 bchars).
- ///
- /// `bchars = bcharsnospace / " "`
- /// `bcharsnospace = DIGIT / ALPHA / "'" / "(" / ")" / "+" / "_" / "," / "-" / "." / "/" / ":" / "=" / "?"`
- private static func isBoundaryChar(_ c: Character) -> Bool {
- guard let ascii = c.asciiValue else { return false }
- switch ascii {
- case UInt8(ascii: "A")...UInt8(ascii: "Z"),
- UInt8(ascii: "a")...UInt8(ascii: "z"),
- UInt8(ascii: "0")...UInt8(ascii: "9"):
- return true
- case UInt8(ascii: "'"), UInt8(ascii: "("), UInt8(ascii: ")"),
- UInt8(ascii: "+"), UInt8(ascii: "_"), UInt8(ascii: ","),
- UInt8(ascii: "-"), UInt8(ascii: "."), UInt8(ascii: "/"),
- UInt8(ascii: ":"), UInt8(ascii: "="), UInt8(ascii: "?"),
- UInt8(ascii: " "):
- return true
- default:
- return false
- }
- }
-
- #if canImport(Network)
- /// Converts this parsed request into a `URLRequest` using the given base URL.
- ///
- /// The `target` (path and query) is resolved against `baseURL` to produce the
- /// final request URL. If a body is present, it is attached as an `httpBodyStream`.
- ///
- /// - Parameter baseURL: The base URL to resolve the request target against.
- /// - Returns: A configured `URLRequest`, or `nil` if the URL cannot be constructed.
- public func urlRequest(relativeTo baseURL: URL) -> URLRequest? {
- guard let url = URL(string: target, relativeTo: baseURL) else {
- return nil
- }
-
- var request = URLRequest(url: url)
- request.httpMethod = method
-
- // RFC 9110 §7.6.1: hop-by-hop headers must not be forwarded by proxies.
- // "proxy-authorization" and "relay-authorization" carry the proxy's
- // own bearer token and must not be forwarded to the upstream server.
- // "authorization" is intentionally kept so that the client's own
- // credentials (e.g. HTTP Basic for the upstream server) pass through.
- var hopByHop: Set = [
- "host", "connection", "transfer-encoding", "keep-alive",
- "proxy-connection", "te", "upgrade", "trailer",
- "proxy-authorization", "relay-authorization",
- ]
-
- // Headers listed in Connection are also hop-by-hop (RFC 9110 §7.6.1).
- if let connectionValue = header("Connection") {
- for name in connectionValue.split(separator: ",") {
- hopByHop.insert(name.trimmingCharacters(in: .whitespaces).lowercased())
- }
- }
-
- for (key, value) in headers {
- guard !hopByHop.contains(key.lowercased()) else { continue }
- request.setValue(value, forHTTPHeaderField: key)
- }
-
- if let body {
- request.httpBodyStream = try? body.makeInputStream()
- }
-
- return request
- }
- #endif
-}
diff --git a/ios/Sources/GutenbergKitHTTP/README.md b/ios/Sources/GutenbergKitHTTP/README.md
deleted file mode 100644
index 86c14e380..000000000
--- a/ios/Sources/GutenbergKitHTTP/README.md
+++ /dev/null
@@ -1,202 +0,0 @@
-# GutenbergKitHTTP
-
-A zero-dependency Swift module providing an HTTP/1.1 request parser and a lightweight local server built on Network.framework. Designed for use as the front-end of an in-process HTTP proxy server, where raw bytes arrive over a socket and need to be converted into structured request objects suitable for forwarding via `URLSession`.
-
-## Why
-
-GutenbergKit's iOS integration uses an in-process HTTP server to bridge requests between the embedded web editor and native networking. This module handles the parsing side of that bridge — turning raw TCP bytes into `URLRequest` objects — without pulling in a full HTTP server framework.
-
-Key design goals:
-
-- **Incremental parsing** — data can arrive in arbitrary chunks (byte-by-byte if needed); the parser buffers to disk so memory usage stays flat regardless of body size.
-- **Lazy validation** — `append()` does only lightweight scanning (finding `\r\n\r\n` and extracting `Content-Length`). Full RFC validation is deferred to `parseRequest()`, keeping the hot path fast.
-- **Strict conformance** — rejects request smuggling vectors (obs-fold, whitespace before colon), validates `Content-Length` per RFC 9110 §8.6, and combines duplicate headers per RFC 9110 §5.3.
-- **No dependencies** — uses only Foundation.
-
-## Types
-
-| Type | Role |
-|------|------|
-| `HTTPServer` | Local HTTP/1.1 server on Network.framework. Binds to `127.0.0.1`, dispatches requests to an async handler. |
-| `HTTPResponse` | Response struct with status, headers, and body. Can be initialized from a `(Data, URLResponse)` tuple for proxying. |
-| `HTTPRequestParser` | Incremental, stateful parser. Feed it bytes with `append(_:)`, check `state`, then call `parseRequest()`. |
-| `HTTPRequestSerializer` | Stateless header parser. Call `parseHeaders(from:)` with a complete `Data` buffer. |
-| `ParsedHTTPRequest` | The result — either `.partial` (headers only) or `.complete` (headers + body). |
-| `MultipartPart` | A parsed multipart/form-data part with `name`, `filename`, `contentType`, and `body`. |
-| `RequestBody` | Abstracts body storage (in-memory or file-backed). Provides `count` (O(1) byte count), `data` (async accessor), and `makeInputStream()`. |
-| `HTTPRequestParseError` | Error enum covering all rejection reasons, with human-readable `localizedDescription` messages. |
-
-## Usage
-
-### Running a local server
-
-`HTTPServer` listens on the loopback interface (`127.0.0.1`) using Network.framework. Each incoming request is parsed automatically and delivered to your handler as a `ServerRequest`, which bundles the parsed HTTP request with timing diagnostics (`.parseDuration`). Your handler returns an `HTTPResponse` — the server serializes it back over the socket.
-
-```swift
-import GutenbergKitHTTP
-
-let server = try await HTTPServer.start(name: "my-server", port: 8080) { req in
- print("\(req.parsed.method) \(req.parsed.target) (\(req.parseDuration))")
- return HTTPResponse(status: 200, body: Data("OK".utf8))
-}
-print("Listening on port \(server.port)")
-// ... later ...
-server.stop()
-```
-
-Pass `nil` (or omit `port`) to let the system assign an available port — useful for tests or when running multiple servers.
-
-#### Handlers with state
-
-A closure is right for a handler that needs no state. When the handler has dependencies, conform a type to `HTTPRequestHandler` and pass it as `handler:` instead — the dependencies become stored properties and the request logic becomes instance methods, rather than statics threading a context parameter through every call.
-
-```swift
-struct MediaHandler: HTTPRequestHandler {
- let uploader: Uploader
-
- func handle(_ request: HTTPServer.Request) async -> HTTPResponse {
- await uploader.upload(request.parsed.body)
- }
-}
-
-let server = try await HTTPServer.start(name: "media", handler: MediaHandler(uploader: uploader))
-```
-
-The server retains its handler, so a handler must not strongly hold the object that owns the server, directly or transitively — `owner → HTTPServer → handler → owner` is a cycle, and the owner's `deinit` would never run. A value type is **not** protection here: a `struct` handler storing the owner closes the same ring, because the server captures the struct into a heap node and its stored properties are strong edges out of it. `HTTPRequestHandler` is deliberately not `AnyObject`-constrained so a handler *can* be a `struct` holding only what it needs — not because a `struct` is safe by construction. Either shape works, as long as it stays a leaf.
-
-When `requiresAuthentication` is enabled (the default), each request must include a `Proxy-Authorization: Bearer ` header carrying the server's randomly-generated token. The server uses `Proxy-Authorization` per RFC 9110 §11.7.1 rather than `Authorization`, so the client's `Authorization` header remains available for upstream credentials (e.g. HTTP Basic auth to the remote server). Unauthenticated requests receive a `407 Proxy Authentication Required` response with a `Proxy-Authenticate: Bearer` challenge header.
-
-### Proxying via URLSession
-
-The most common use case: forward web editor requests to a remote WordPress site. `HTTPResponse` has a convenience initializer that accepts a `(Data, URLResponse)` tuple, so you can pipe `URLSession` results directly back.
-
-```swift
-let server = try await HTTPServer.start { req in
- let url = URL(string: "https://example.com\(req.parsed.target)")!
- var upstream = URLRequest(url: url)
- upstream.httpMethod = req.parsed.method
- return try await HTTPResponse(URLSession.shared.data(for: upstream))
-}
-```
-
-### One-shot parsing
-
-Use this when the full HTTP request is already in memory (e.g., from a test fixture or a buffered read). Pass the raw string or `Data` to the parser's convenience initializer, then call `parseRequest()` to get a `ParsedHTTPRequest`.
-
-```swift
-import GutenbergKitHTTP
-
-let raw = "POST /wp/v2/posts HTTP/1.1\r\nHost: localhost\r\nContent-Length: 13\r\n\r\n{\"title\":\"Hi\"}"
-let parser = HTTPRequestParser(raw)
-
-let request = try parser.parseRequest()!
-print(request.method) // "POST"
-print(request.target) // "/wp/v2/posts"
-print(request.header("Host")) // Optional("localhost")
-```
-
-The `header(_:)` method performs case-insensitive lookup per RFC 9110.
-
-### Incremental parsing
-
-Use this when data arrives in chunks from a socket. Call `append(_:)` as bytes arrive — the parser buffers body data to a temporary file so memory stays flat even for large uploads. Check `state` to decide when to parse.
-
-```swift
-let parser = HTTPRequestParser()
-
-// Feed data as it arrives
-parser.append(firstChunk)
-parser.append(secondChunk)
-
-switch parser.state {
-case .needsMoreData:
- // Keep reading from the socket
- break
-case .headersComplete:
- // Headers are available but body is still arriving
- let partial = try parser.parseRequest()!
- print(partial.method, partial.target)
-case .complete:
- // Everything received — parse and forward
- let request = try parser.parseRequest()!
- // ...
-}
-```
-
-You can call `parseRequest()` in either `.headersComplete` or `.complete` state. In `.headersComplete`, the returned `ParsedHTTPRequest` is `.partial` (headers only, no body). In `.complete`, it is `.complete` with the full body available via `request.body`.
-
-### Converting to URLRequest for forwarding
-
-`ParsedHTTPRequest` can generate a Foundation `URLRequest` for forwarding to a remote server. Pass a base URL — the request's target path is resolved relative to it. The method, headers, and body are carried over automatically.
-
-```swift
-let baseURL = URL(string: "https://example.com")!
-if let urlRequest = request.urlRequest(relativeTo: baseURL) {
- let (data, response) = try await URLSession.shared.data(for: urlRequest)
-}
-```
-
-### Multipart parsing
-
-For `multipart/form-data` requests (e.g., media uploads), call `multipartParts()` on a parsed request. The boundary is extracted automatically from the `Content-Type` header. Each `MultipartPart` gives you the field `name`, optional `filename` and `contentType`, and a `body` backed by the same `RequestBody` abstraction (in-memory or file-backed).
-
-```swift
-let request = try parser.parseRequest()!
-let parts = try request.multipartParts()
-
-for part in parts {
- print("\(part.name): \(try readAll(part.body))")
- if let filename = part.filename {
- print(" filename: \(filename), contentType: \(part.contentType ?? "unknown")")
- }
-}
-```
-
-### Error handling
-
-`parseRequest()` throws `HTTPRequestParseError` for malformed input. Each case maps to a specific RFC violation or safety check, and carries a human-readable `localizedDescription`.
-
-```swift
-do {
- let request = try parser.parseRequest()
-} catch let error as HTTPRequestParseError {
- switch error {
- case .emptyHeaderSection: // No request line before \r\n\r\n
- case .malformedRequestLine: // Missing method or target
- case .obsFoldDetected: // Continuation line (rejected per RFC 7230 §3.2.4)
- case .whitespaceBeforeColon: // Space or tab between field-name and colon (RFC 7230 §3.2.4)
- case .invalidContentLength: // Non-numeric or negative Content-Length
- case .conflictingContentLength: // Multiple Content-Length headers disagree
- case .unsupportedTransferEncoding: // Transfer-Encoding not supported
- case .invalidHTTPVersion: // Unrecognized HTTP version
- case .invalidFieldName: // Invalid characters in header field name
- case .invalidFieldValue: // Invalid characters in header field value
- case .missingHostHeader: // HTTP/1.1 requires Host
- case .multipleHostHeaders: // Duplicate Host headers
- case .payloadTooLarge: // Body exceeds maxBodySize (HTTP 413)
- case .headersTooLarge: // Headers exceed limit (HTTP 431)
- case .invalidEncoding: // Headers aren't valid UTF-8
- }
-
- // All cases provide a human-readable description:
- print(error.localizedDescription)
-}
-```
-
-You can also limit the maximum body size by passing `maxBodySize` to the parser initializer — requests exceeding the limit throw `.payloadTooLarge`.
-
-## RFC Conformance
-
-The parser enforces or documents behavior for the following:
-
-- **RFC 7230 §3.2.4** — Rejects obs-fold (continuation lines) and whitespace before colon in field names.
-- **RFC 7230 §3.3.3** — Rejects conflicting `Content-Length` values across multiple headers.
-- **RFC 9110 §5.3** — Combines duplicate header field lines with comma-separated values.
-- **RFC 9110 §8.6** — Validates `Content-Length` values including comma-separated lists of identical values (e.g., `5, 5`).
-- **RFC 9112 §3** — Parses the request line into method, target, and optional HTTP version.
-
-Conformance is verified by 115+ tests across `HTTPRequestParserTests`, `RFC7230ConformanceTests`, and `RFC9112ConformanceTests`, plus shared cross-platform JSON test fixtures (also used by the Kotlin test suite).
-
-## Debug Server
-
-The companion `GutenbergKitDebugServer` executable provides a ready-made server for manual testing. See its [README](../GutenbergKitDebugServer/README.md) for details.
diff --git a/ios/Sources/GutenbergKitHTTP/RequestBody.swift b/ios/Sources/GutenbergKitHTTP/RequestBody.swift
deleted file mode 100644
index dc22f9ef1..000000000
--- a/ios/Sources/GutenbergKitHTTP/RequestBody.swift
+++ /dev/null
@@ -1,300 +0,0 @@
-import Foundation
-
-/// Process-wide registry of temp files currently backing an in-flight request.
-///
-/// ``HTTPServer/cleanOrphanedTempFiles(in:)`` runs a delete-all sweep of a
-/// server's temp directory on start to reclaim files orphaned by a crash. Two
-/// server instances that share a name share that directory (e.g. two editors
-/// open at once, or one being torn down as another starts), so the sweep would
-/// otherwise delete the other instance's live buffers. Registering a file here
-/// while it is in use makes the sweep skip it; files not registered are crash
-/// orphans (no live owner in this process) and are removed.
-///
-/// Keyed by file name (a unique UUID), which is stable however the directory is
-/// later enumerated.
-enum ActiveTempFiles {
- private static let lock = NSLock()
- // Guarded by `lock` on every access.
- nonisolated(unsafe) private static var names = Set()
-
- static func register(_ name: String) { lock.withLock { _ = names.insert(name) } }
- static func unregister(_ name: String) { lock.withLock { _ = names.remove(name) } }
- static func contains(_ name: String) -> Bool { lock.withLock { names.contains(name) } }
-}
-
-/// Reference-counted owner for a temporary file.
-///
-/// The file is deleted when the last reference is released. This allows
-/// ``RequestBody`` (a value type) to share ownership of a temp file across
-/// copies — including multipart part bodies that reference byte ranges within
-/// the same file. While owned, the file is registered in ``ActiveTempFiles`` so
-/// a concurrent server's orphan sweep won't delete it.
-final class TempFileOwner: Sendable {
- let url: URL
- init(url: URL) {
- self.url = url
- ActiveTempFiles.register(url.lastPathComponent)
- }
- deinit {
- ActiveTempFiles.unregister(url.lastPathComponent)
- try? FileManager.default.removeItem(at: url)
- }
-}
-
-/// An HTTP request body with stream semantics.
-///
-/// `RequestBody` abstracts over the underlying storage (in-memory data or a file on disk)
-/// and provides uniform access regardless of backing:
-/// - **Stream access**: Use ``makeInputStream()`` to read without loading everything into memory.
-/// - **Materialized access**: Use ``data`` to get the full contents. For file-backed bodies,
-/// this reads the entire file into memory.
-public struct RequestBody: Sendable, Equatable {
-
- enum Storage: Sendable, Equatable {
- case data(Data)
- case file(URL)
- case fileSlice(url: URL, offset: UInt64, length: Int)
- }
-
- let storage: Storage
-
- /// Retains the backing file for parser-created bodies.
- /// Ignored in equality comparisons.
- private let _owner: TempFileOwner?
-
- public static func == (lhs: RequestBody, rhs: RequestBody) -> Bool {
- lhs.storage == rhs.storage
- }
-
- /// Creates a body backed by in-memory data.
- public init(data: Data) {
- self.storage = .data(data)
- self._owner = nil
- }
-
- /// Creates a body backed by a file on disk.
- ///
- /// The caller is responsible for ensuring the file exists for the lifetime of the body.
- public init(fileURL: URL) {
- self.storage = .file(fileURL)
- self._owner = nil
- }
-
- /// Creates a body backed by a byte range within a file on disk.
- ///
- /// The bytes are not read until ``makeInputStream()`` is called, keeping the
- /// representation lightweight for use cases like multipart part bodies.
- init(fileURL: URL, offset: UInt64, length: Int) {
- self.storage = .fileSlice(url: fileURL, offset: offset, length: length)
- self._owner = nil
- }
-
- /// Creates a body backed by an owned temporary file.
- ///
- /// The file is automatically deleted when the last `RequestBody` referencing it
- /// (including multipart part bodies derived from it) is released.
- init(ownedFileURL: URL) {
- self.storage = .file(ownedFileURL)
- self._owner = TempFileOwner(url: ownedFileURL)
- }
-
- /// Creates a body backed by a byte range within a file, sharing ownership
- /// with the source body's temp file.
- init(fileURL: URL, offset: UInt64, length: Int, owner: TempFileOwner?) {
- self.storage = .fileSlice(url: fileURL, offset: offset, length: length)
- self._owner = owner
- }
-
- /// The temp file owner, if any. Used to propagate ownership to derived bodies
- /// (e.g., multipart part slices).
- var fileOwner: TempFileOwner? { _owner }
-
- /// The number of bytes in the body.
- ///
- /// For in-memory and file-slice bodies this is O(1). For file-backed bodies
- /// this queries the file system without reading any data.
- public var count: Int {
- switch storage {
- case .data(let data):
- return data.count
- case .file(let url):
- return (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
- case .fileSlice(_, _, let length):
- return length
- }
- }
-
- /// The file URL backing this body, or `nil` for in-memory bodies.
- public var fileURL: URL? {
- switch storage {
- case .data: return nil
- case .file(let url), .fileSlice(let url, _, _): return url
- }
- }
-
- /// The byte offset within the backing file where this body begins.
- /// Returns 0 for in-memory and whole-file bodies.
- public var fileOffset: UInt64 {
- switch storage {
- case .fileSlice(_, let offset, _): return offset
- default: return 0
- }
- }
-
- /// The in-memory data backing this body, or `nil` for file-backed bodies.
- ///
- /// Unlike ``data``, this does **not** read from disk.
- public var inMemoryData: Data? {
- switch storage {
- case .data(let data): return data
- default: return nil
- }
- }
-
- /// The full body contents as `Data`.
- ///
- /// For in-memory bodies this returns the data directly. For file-backed
- /// bodies the file is read on a background thread to avoid blocking.
- ///
- /// You should almost always use `InputStream` instead.
- public var data: Data {
- get async throws {
- switch storage {
- case .data(let data):
- return data
- case .file(let url):
- return try Data(contentsOf: url)
- case .fileSlice(let url, let offset, let length):
- return try FileHandle.withReadHandle(forUrl: url) {
- try $0.seek(toOffset: offset)
- return try $0.read(upToCount: length) ?? Data()
- }
- }
- }
- }
-
- /// Creates an `InputStream` for reading the body contents.
- ///
- /// For file-slice bodies, a bound stream pair is used instead of subclassing
- /// `InputStream`. Subclassing `InputStream` with `super.init(data:)` triggers
- /// Foundation's class cluster design, causing `URLSession` to read from the
- /// empty superclass `Data` instead of the overridden `read(_:maxLength:)`.
- /// The bound stream pair avoids this because `Stream.getBoundStreams` returns
- /// a native `InputStream` that `URLSession` handles correctly.
- ///
- /// - Throws: A `CocoaError` if the backing file does not exist, is not readable, or is a directory.
- public func makeInputStream() throws -> InputStream {
- switch storage {
- case .data(let data):
- return InputStream(data: data)
- case .file(let url):
- return try Self.openFileStream(url: url)
- case .fileSlice(let url, let offset, let length):
- return try Self.makePipedFileSliceStream(url: url, offset: offset, length: length)
- }
- }
-
- /// Reads the entire body contents into memory and returns the data along with the
- /// file offset at which the data begins (0 for in-memory bodies).
- ///
- /// This is intended for scanning operations (e.g., multipart boundary detection)
- /// where the full body must be examined. The caller should release the returned
- /// `Data` as soon as scanning is complete.
- func readAllData() throws -> (Data, UInt64) {
- return switch storage {
- case .data(let data): (data, 0)
- case .file(let url): try (Data(contentsOf: url), 0)
- case .fileSlice(let url, let offset, let length):
- try FileHandle.withReadHandle(forUrl: url) {
- try $0.seek(toOffset: offset)
- return (try $0.read(upToCount: length) ?? Data(), offset)
- }
- }
- }
-
- /// Creates an `InputStream` backed by a bound stream pair that reads a byte
- /// range from a file on a background thread.
- ///
- /// The writer thread reads chunks from the file and pushes them into the
- /// `OutputStream` end of the pair. The returned `InputStream` is a native
- /// Foundation stream that `URLSession` and other consumers handle correctly.
- /// Backpressure is automatic: `OutputStream.write` blocks when the internal
- /// buffer is full.
- private static func makePipedFileSliceStream(url: URL, offset: UInt64, length: Int) throws -> InputStream {
- guard length > 0 else {
- return InputStream(data: Data())
- }
-
- let fileHandle = try FileHandle(forReadingFrom: url)
- try fileHandle.seek(toOffset: offset)
-
- var readStream: InputStream?
- var writeStream: OutputStream?
- Stream.getBoundStreams(withBufferSize: 65_536, inputStream: &readStream, outputStream: &writeStream)
-
- guard let inputStream = readStream, let outputStream = writeStream else {
- try? fileHandle.close()
- throw CocoaError(.fileReadUnknown, userInfo: [NSFilePathErrorKey: url.path])
- }
-
- outputStream.open()
-
- // OutputStream is not Sendable but is safely transferred to the
- // writer thread — only the thread accesses it after this point.
- nonisolated(unsafe) let output = outputStream
-
- Thread.detachNewThread {
- defer {
- output.close()
- try? fileHandle.close()
- }
-
- var remaining = length
- while remaining > 0 {
- let chunkSize = min(65_536, remaining)
- guard let chunk = try? fileHandle.read(upToCount: chunkSize),
- !chunk.isEmpty else {
- break
- }
-
- var written = 0
- chunk.withUnsafeBytes { buffer in
- guard let base = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return }
- while written < chunk.count {
- let result = output.write(base.advanced(by: written), maxLength: chunk.count - written)
- if result <= 0 { return }
- written += result
- }
- }
-
- if written < chunk.count { break }
- remaining -= chunk.count
- }
- }
-
- return inputStream
- }
-
- private static func openFileStream(url: URL) throws -> InputStream {
- let path = url.path
- var isDirectory: ObjCBool = false
-
- guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else {
- throw CocoaError(.fileNoSuchFile, userInfo: [NSFilePathErrorKey: path])
- }
-
- guard !isDirectory.boolValue else {
- throw CocoaError(.fileReadInvalidFileName, userInfo: [NSFilePathErrorKey: path])
- }
-
- guard FileManager.default.isReadableFile(atPath: path) else {
- throw CocoaError(.fileReadNoPermission, userInfo: [NSFilePathErrorKey: path])
- }
-
- guard let stream = InputStream(url: url) else {
- throw CocoaError(.fileReadUnknown, userInfo: [NSFilePathErrorKey: path])
- }
-
- return stream
- }
-}
diff --git a/ios/Tests/GutenbergKitHTTPTests/BoundStreamTeardownTests.swift b/ios/Tests/GutenbergKitHTTPTests/BoundStreamTeardownTests.swift
deleted file mode 100644
index 9450722e3..000000000
--- a/ios/Tests/GutenbergKitHTTPTests/BoundStreamTeardownTests.swift
+++ /dev/null
@@ -1,51 +0,0 @@
-import Foundation
-import Testing
-
-/// Verifies the assumption behind `MediaUploadServer.performUpload`'s
-/// `defer { request.httpBodyStream?.close() }`: closing the input side of a bound
-/// stream pair unblocks a writer that is blocked on a full output buffer. Without
-/// that, a consumer (URLSession) that abandons the body stream on cancel/failure
-/// without draining it would leave the background writer thread blocked forever,
-/// leaking the thread and its open file handle.
-@Suite("Bound Stream Teardown")
-struct BoundStreamTeardownTests {
-
- @Test("closing the input stream unblocks a blocked bound-pair writer")
- func closingInputUnblocksBlockedWriter() throws {
- var readStream: InputStream?
- var writeStream: OutputStream?
- Stream.getBoundStreams(withBufferSize: 1024, inputStream: &readStream, outputStream: &writeStream)
- let input = try #require(readStream)
- let output = try #require(writeStream)
- input.open()
- output.open()
-
- let exited = DispatchSemaphore(value: 0)
- // OutputStream is not Sendable; only the writer thread touches it after this.
- nonisolated(unsafe) let out = output
- Thread.detachNewThread {
- // Write far more than the 1 KB buffer with nobody reading the input —
- // once the buffer fills, `write` blocks (backpressure).
- let chunk = [UInt8](repeating: 0, count: 256 * 1024)
- chunk.withUnsafeBufferPointer { buffer in
- guard let base = buffer.baseAddress else { return }
- var written = 0
- while written < chunk.count {
- let result = out.write(base + written, maxLength: chunk.count - written)
- if result <= 0 { break }
- written += result
- }
- }
- out.close()
- exited.signal()
- }
-
- // The writer should be blocked on the full buffer (nothing is reading).
- #expect(exited.wait(timeout: .now() + .milliseconds(300)) == .timedOut)
-
- // Closing the input breaks the pair; the blocked `write` should fail and the
- // writer thread should unwind and exit.
- input.close()
- #expect(exited.wait(timeout: .now() + .seconds(3)) == .success)
- }
-}
diff --git a/ios/Tests/GutenbergKitHTTPTests/ChunkedMultipartTests.swift b/ios/Tests/GutenbergKitHTTPTests/ChunkedMultipartTests.swift
deleted file mode 100644
index cf26dfbd4..000000000
--- a/ios/Tests/GutenbergKitHTTPTests/ChunkedMultipartTests.swift
+++ /dev/null
@@ -1,342 +0,0 @@
-import Foundation
-import Testing
-@testable import GutenbergKitHTTP
-
-/// Tests for the chunked (file-backed) multipart parsing path.
-///
-/// The in-memory path is tested extensively in ``RFC7578ConformanceTests`` and
-/// the shared fixture tests. These tests verify the chunked scanner that runs
-/// when the body is backed by a file on disk.
-@Suite("Chunked Multipart Parsing")
-struct ChunkedMultipartTests {
-
- // MARK: - Basic Parsing
-
- @Test("single text field parsed from file-backed body")
- func singleTextField() throws {
- let (url, request) = try makeFileBackedRequest(
- fields: [("title", nil, nil, Data("My Blog Post".utf8))],
- boundary: "AaB03x"
- )
- defer { try? FileManager.default.removeItem(at: url) }
-
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "title")
- #expect(parts[0].filename == nil)
- #expect(parts[0].contentType == "text/plain")
- #expect(try readAll(parts[0].body) == Data("My Blog Post".utf8))
- }
-
- @Test("multiple parts parsed from file-backed body")
- func multipleParts() throws {
- let (url, request) = try makeFileBackedRequest(
- fields: [
- ("title", nil, nil, Data("Hello".utf8)),
- ("file", "photo.jpg", "image/jpeg", Data("jpeg-data".utf8)),
- ("caption", nil, nil, Data("A photo".utf8)),
- ],
- boundary: "WebKitBoundary123"
- )
- defer { try? FileManager.default.removeItem(at: url) }
-
- let parts = try request.multipartParts()
-
- #expect(parts.count == 3)
- #expect(parts[0].name == "title")
- #expect(try readAll(parts[0].body) == Data("Hello".utf8))
- #expect(parts[1].name == "file")
- #expect(parts[1].filename == "photo.jpg")
- #expect(parts[1].contentType == "image/jpeg")
- #expect(try readAll(parts[1].body) == Data("jpeg-data".utf8))
- #expect(parts[2].name == "caption")
- #expect(try readAll(parts[2].body) == Data("A photo".utf8))
- }
-
- @Test("empty part body parsed correctly")
- func emptyPartBody() throws {
- let (url, request) = try makeFileBackedRequest(
- fields: [("empty", nil, nil, Data())],
- boundary: "AaB03x"
- )
- defer { try? FileManager.default.removeItem(at: url) }
-
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "empty")
- #expect(try readAll(parts[0].body) == Data())
- }
-
- @Test("binary data preserved through file-backed parsing")
- func binaryData() throws {
- // Include bytes that would be problematic if treated as text: NUL, 0xFF, CRLF sequences.
- var binaryContent = Data(repeating: 0x00, count: 128)
- binaryContent.append(contentsOf: (0...255).map { UInt8($0) })
- binaryContent.append(Data(repeating: 0xFF, count: 128))
-
- let (url, request) = try makeFileBackedRequest(
- fields: [("file", "binary.bin", "application/octet-stream", binaryContent)],
- boundary: "BinaryBoundary99"
- )
- defer { try? FileManager.default.removeItem(at: url) }
-
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].filename == "binary.bin")
- #expect(try readAll(parts[0].body) == binaryContent)
- }
-
- @Test("preamble before first boundary is ignored")
- func preambleIgnored() throws {
- let boundary = "AaB03x"
- let preamble = "This is the preamble. It should be ignored.\r\n"
- let body = "\(preamble)--\(boundary)\r\nContent-Disposition: form-data; name=\"field\"\r\n\r\nvalue\r\n--\(boundary)--\r\n"
-
- let (url, request) = try makeFileBackedRequestFromRawBody(body: body, boundary: boundary)
- defer { try? FileManager.default.removeItem(at: url) }
-
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "field")
- #expect(try readAll(parts[0].body) == Data("value".utf8))
- }
-
- @Test("transport padding after boundary is skipped")
- func transportPadding() throws {
- let boundary = "AaB03x"
- // Add spaces and tabs after the boundary delimiter
- let body = "--\(boundary) \t \r\nContent-Disposition: form-data; name=\"field\"\r\n\r\nvalue\r\n--\(boundary)--\r\n"
-
- let (url, request) = try makeFileBackedRequestFromRawBody(body: body, boundary: boundary)
- defer { try? FileManager.default.removeItem(at: url) }
-
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "field")
- #expect(try readAll(parts[0].body) == Data("value".utf8))
- }
-
- // MARK: - Error Cases
-
- @Test("close-delimiter-only body throws malformedBody")
- func closeDelimiterOnly() throws {
- let boundary = "AaB03x"
- let body = "--\(boundary)--\r\n"
-
- let (url, request) = try makeFileBackedRequestFromRawBody(body: body, boundary: boundary)
- defer { try? FileManager.default.removeItem(at: url) }
-
- #expect(throws: MultipartParseError.malformedBody) {
- try request.multipartParts()
- }
- }
-
- @Test("missing close delimiter throws malformedBody")
- func missingCloseDelimiter() throws {
- let boundary = "AaB03x"
- let body = "--\(boundary)\r\nContent-Disposition: form-data; name=\"field\"\r\n\r\nvalue"
-
- let (url, request) = try makeFileBackedRequestFromRawBody(body: body, boundary: boundary)
- defer { try? FileManager.default.removeItem(at: url) }
-
- #expect(throws: MultipartParseError.malformedBody) {
- try request.multipartParts()
- }
- }
-
- // MARK: - Chunk Boundary Edge Cases
-
- @Test("boundary split across chunk boundary is found correctly")
- func boundarySplitAcrossChunk() throws {
- let boundary = "AaB03x"
- let delimiter = "--\(boundary)" // 10 bytes
-
- // We want the second delimiter to start 5 bytes before the 65536 chunk boundary,
- // so it straddles the boundary: 5 bytes in chunk 1, 5 bytes in chunk 2.
- let splitPoint = 65_536 - 5
-
- // Calculate the header overhead for the first part:
- // "--AaB03x\r\n" = 12 bytes
- // "Content-Disposition: form-data; name=\"pad\"\r\n" = 45 bytes
- // "\r\n" = 2 bytes (header/body separator)
- // Total: 59 bytes
- // After the body: "\r\n" = 2 bytes (CRLF before next delimiter)
- // So: padding_length = splitPoint - 59 - 2 = splitPoint - 61
- let headerOverhead = Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"pad\"\r\n\r\n".utf8).count
- let crlfBeforeDelimiter = 2
- let paddingLength = splitPoint - headerOverhead - crlfBeforeDelimiter
-
- let padding = Data(repeating: UInt8(ascii: "A"), count: paddingLength)
-
- let (url, request) = try makeFileBackedRequest(
- fields: [
- ("pad", nil, nil, padding),
- ("after", nil, nil, Data("found-it".utf8)),
- ],
- boundary: boundary
- )
- defer { try? FileManager.default.removeItem(at: url) }
-
- // Verify the delimiter actually straddles the chunk boundary.
- let fileData = try Data(contentsOf: url)
- let delimData = Data(delimiter.utf8)
- if let range = fileData.range(of: delimData, in: (fileData.startIndex + headerOverhead).. 128 KB).
- let largeContent = Data(repeating: UInt8(ascii: "X"), count: 200_000)
-
- let (url, request) = try makeFileBackedRequest(
- fields: [
- ("large", "big.bin", "application/octet-stream", largeContent),
- ("meta", nil, nil, Data("description".utf8)),
- ],
- boundary: "LargeBoundary42"
- )
- defer { try? FileManager.default.removeItem(at: url) }
-
- let parts = try request.multipartParts()
-
- #expect(parts.count == 2)
- #expect(parts[0].name == "large")
- #expect(parts[0].body.count == largeContent.count)
- #expect(try readAll(parts[0].body) == largeContent)
- #expect(parts[1].name == "meta")
- #expect(try readAll(parts[1].body) == Data("description".utf8))
- }
-
- // MARK: - fileSlice Source
-
- @Test("file-backed body with non-zero offset (fileSlice) parses correctly")
- func fileSliceSource() throws {
- let boundary = "AaB03x"
- let multipartBody = "--\(boundary)\r\nContent-Disposition: form-data; name=\"field\"\r\n\r\nvalue\r\n--\(boundary)--\r\n"
- let multipartData = Data(multipartBody.utf8)
-
- // Write garbage prefix + multipart body to the file.
- let garbagePrefix = Data(repeating: UInt8(ascii: "Z"), count: 500)
- let url = FileManager.default.temporaryDirectory
- .appendingPathComponent("slice-test-\(UUID().uuidString)")
- try (garbagePrefix + multipartData).write(to: url)
- defer { try? FileManager.default.removeItem(at: url) }
-
- // Create a fileSlice body that starts after the garbage prefix.
- let body = RequestBody(fileURL: url, offset: UInt64(garbagePrefix.count), length: multipartData.count)
- let request = ParsedHTTPRequest.complete(
- method: "POST",
- target: "/upload",
- httpVersion: "HTTP/1.1",
- headers: ["Content-Type": "multipart/form-data; boundary=\(boundary)", "Host": "localhost"],
- body: body
- )
-
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "field")
- #expect(try readAll(parts[0].body) == Data("value".utf8))
- }
-
- // MARK: - Helpers
-
- /// Builds a multipart body from field descriptors, writes it to a temp file,
- /// and returns a `ParsedHTTPRequest` with a file-backed body.
- private func makeFileBackedRequest(
- fields: [(name: String, filename: String?, contentType: String?, value: Data)],
- boundary: String
- ) throws -> (URL, ParsedHTTPRequest) {
- var body = Data()
- for field in fields {
- body.append(Data("--\(boundary)\r\n".utf8))
- var disposition = "Content-Disposition: form-data; name=\"\(field.name)\""
- if let filename = field.filename {
- disposition += "; filename=\"\(filename)\""
- }
- body.append(Data("\(disposition)\r\n".utf8))
- if let ct = field.contentType {
- body.append(Data("Content-Type: \(ct)\r\n".utf8))
- }
- body.append(Data("\r\n".utf8))
- body.append(field.value)
- body.append(Data("\r\n".utf8))
- }
- body.append(Data("--\(boundary)--\r\n".utf8))
-
- let url = FileManager.default.temporaryDirectory
- .appendingPathComponent("multipart-test-\(UUID().uuidString)")
- try body.write(to: url)
-
- let requestBody = RequestBody(fileURL: url)
- let request = ParsedHTTPRequest.complete(
- method: "POST",
- target: "/upload",
- httpVersion: "HTTP/1.1",
- headers: ["Content-Type": "multipart/form-data; boundary=\(boundary)", "Host": "localhost"],
- body: requestBody
- )
-
- return (url, request)
- }
-
- /// Writes a raw multipart body string to a temp file and returns a file-backed request.
- private func makeFileBackedRequestFromRawBody(
- body: String,
- boundary: String
- ) throws -> (URL, ParsedHTTPRequest) {
- let bodyData = Data(body.utf8)
- let url = FileManager.default.temporaryDirectory
- .appendingPathComponent("multipart-test-\(UUID().uuidString)")
- try bodyData.write(to: url)
-
- let requestBody = RequestBody(fileURL: url)
- let request = ParsedHTTPRequest.complete(
- method: "POST",
- target: "/upload",
- httpVersion: "HTTP/1.1",
- headers: ["Content-Type": "multipart/form-data; boundary=\(boundary)", "Host": "localhost"],
- body: requestBody
- )
-
- return (url, request)
- }
-
- private func readAll(_ body: RequestBody) throws -> Data {
- let stream = try body.makeInputStream()
- stream.open()
- defer { stream.close() }
-
- var data = Data()
- let bufferSize = 1024
- let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize)
- defer { buffer.deallocate() }
-
- // Use read() directly instead of hasBytesAvailable to avoid race
- // conditions with bound stream pairs, where data from the writer
- // thread may not have arrived yet when hasBytesAvailable is checked.
- while true {
- let bytesRead = stream.read(buffer, maxLength: bufferSize)
- if bytesRead <= 0 { break }
- data.append(buffer, count: bytesRead)
- }
-
- return data
- }
-}
diff --git a/ios/Tests/GutenbergKitHTTPTests/ConnectionTasksTests.swift b/ios/Tests/GutenbergKitHTTPTests/ConnectionTasksTests.swift
deleted file mode 100644
index 60c2b205a..000000000
--- a/ios/Tests/GutenbergKitHTTPTests/ConnectionTasksTests.swift
+++ /dev/null
@@ -1,129 +0,0 @@
-#if canImport(Network)
-
-import Foundation
-import Testing
-@testable import GutenbergKitHTTP
-
-@Suite("ConnectionTasks")
-struct ConnectionTasksTests {
-
- @Test("track registers a task")
- func track() async {
- let tasks = ConnectionTasks()
- let id = UUID()
- let task = Task {}
- tasks.track(id, task)
-
- // Tracking the same ID again should not crash (overwrites)
- let task2 = Task {}
- tasks.track(id, task2)
- }
-
- @Test("cancelAll cancels all tracked tasks")
- func cancelAllCancelsTasks() async {
- let tasks = ConnectionTasks()
-
- let cancelled1 = ManagedAtomic(false)
- let cancelled2 = ManagedAtomic(false)
-
- let id1 = UUID()
- let t1 = Task {
- // Spin until cancelled
- while !Task.isCancelled {
- await Task.yield()
- }
- cancelled1.set(true)
- }
- tasks.track(id1, t1)
-
- let id2 = UUID()
- let t2 = Task {
- while !Task.isCancelled {
- await Task.yield()
- }
- cancelled2.set(true)
- }
- tasks.track(id2, t2)
-
- // Give tasks a moment to start
- await Task.yield()
-
- tasks.cancelAll()
-
- // Wait for tasks to finish
- await t1.value
- await t2.value
-
- #expect(cancelled1.get())
- #expect(cancelled2.get())
- }
-
- @Test("cancelAll is idempotent")
- func cancelAllIdempotent() async {
- let tasks = ConnectionTasks()
-
- let id = UUID()
- let task = Task {
- while !Task.isCancelled {
- await Task.yield()
- }
- }
- tasks.track(id, task)
-
- tasks.cancelAll()
- await task.value
-
- // Second cancelAll should not crash
- tasks.cancelAll()
- }
-
- @Test("cancelAll on already-completed tasks does not crash")
- func cancelAllWithCompletedTasks() async {
- let tasks = ConnectionTasks()
- let id = UUID()
- let task = Task {}
- tasks.track(id, task)
- await task.value
-
- // Task is already done — cancelAll should not crash
- tasks.cancelAll()
- }
-
- @Test("concurrent track does not crash")
- func concurrentAccess() async {
- let tasks = ConnectionTasks()
-
- await withTaskGroup(of: Void.self) { group in
- for _ in 0..<100 {
- group.addTask {
- let id = UUID()
- let task = Task {}
- tasks.track(id, task)
- }
- }
- }
-
- // Final cleanup should not crash
- tasks.cancelAll()
- }
-}
-
-/// Minimal thread-safe boolean for test assertions.
-private final class ManagedAtomic: @unchecked Sendable {
- private let lock = NSLock()
- private var value: Bool
-
- init(_ value: Bool) {
- self.value = value
- }
-
- func get() -> Bool {
- lock.withLock { value }
- }
-
- func set(_ newValue: Bool) {
- lock.withLock { value = newValue }
- }
-}
-
-#endif // canImport(Network)
diff --git a/ios/Tests/GutenbergKitHTTPTests/FixtureTests.swift b/ios/Tests/GutenbergKitHTTPTests/FixtureTests.swift
deleted file mode 100644
index 0acc1726c..000000000
--- a/ios/Tests/GutenbergKitHTTPTests/FixtureTests.swift
+++ /dev/null
@@ -1,465 +0,0 @@
-import Foundation
-import Testing
-@testable import GutenbergKitHTTP
-
-// MARK: - Fixture Models
-
-struct HeaderValueFixtures: Decodable {
- let tests: [HeaderValueTestCase]
-
- struct HeaderValueTestCase: Decodable {
- let description: String
- let parameter: String
- let headerValue: String
- let expected: String?
- }
-}
-
-struct RequestParsingFixtures: Decodable {
- let tests: [RequestTestCase]
- let errorTests: [RequestErrorTestCase]
- let incrementalTests: [IncrementalTestCase]
-
- struct ExpectedAfterHeaders: Decodable {
- var hasHeaders: Bool?
- var isComplete: Bool?
- var method: String?
- var target: String?
- }
-
- struct ExpectedRequest: Decodable {
- var method: String?
- var target: String?
- var headers: [String: String]?
- var isComplete: Bool?
- var hasHeaders: Bool?
- var parseResult: String?
-
- // body uses explicit key presence tracking so we can distinguish
- // "body key absent" (don't check) from "body": null (expect nil)
- private(set) var body: String?
- private(set) var hasBodyExpectation: Bool = false
-
- var afterHeaders: ExpectedAfterHeaders?
-
- private enum CodingKeys: String, CodingKey {
- case method, target, headers, body, isComplete, hasHeaders, parseResult, afterHeaders
- }
-
- init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- method = try container.decodeIfPresent(String.self, forKey: .method)
- target = try container.decodeIfPresent(String.self, forKey: .target)
- headers = try container.decodeIfPresent([String: String].self, forKey: .headers)
- isComplete = try container.decodeIfPresent(Bool.self, forKey: .isComplete)
- hasHeaders = try container.decodeIfPresent(Bool.self, forKey: .hasHeaders)
- parseResult = try container.decodeIfPresent(String.self, forKey: .parseResult)
- hasBodyExpectation = container.contains(.body)
- body = try container.decodeIfPresent(String.self, forKey: .body)
- afterHeaders = try container.decodeIfPresent(ExpectedAfterHeaders.self, forKey: .afterHeaders)
- }
- }
-
- struct RequestTestCase: Decodable {
- let description: String
- let input: String
- let expected: ExpectedRequest
- var appendAfterComplete: String?
- var maxBodySize: Int64?
- }
-
- struct RequestErrorExpected: Decodable {
- let error: String
- }
-
- struct RequestErrorTestCase: Decodable {
- let description: String
- var input: String?
- var inputBase64: String?
- let expected: RequestErrorExpected
- var maxBodySize: Int64?
- }
-
- struct IncrementalTestCase: Decodable {
- let description: String
- var input: String?
- var headers: String?
- var bodyChunks: [String]?
- var chunkSize: Int?
- let expected: ExpectedRequest
- }
-}
-
-struct MultipartFixtures: Decodable {
- let tests: [MultipartTestCase]
- let errorTests: [MultipartErrorTestCase]
-
- struct ExpectedPart: Decodable {
- let name: String
- var filename: String?
- var contentType: String?
- var body: String?
- /// Whether the "filename" key was present in the JSON fixture (distinguishes absent from null).
- var filenameSpecified: Bool = false
-
- private enum CodingKeys: String, CodingKey {
- case name, filename, contentType, body
- }
-
- init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- name = try container.decode(String.self, forKey: .name)
- contentType = try container.decodeIfPresent(String.self, forKey: .contentType)
- body = try container.decodeIfPresent(String.self, forKey: .body)
- if container.contains(.filename) {
- filename = try container.decodeIfPresent(String.self, forKey: .filename)
- filenameSpecified = true
- }
- }
- }
-
- struct Expected: Decodable {
- var contentType: String?
- var parts: [ExpectedPart]?
- var error: String?
- }
-
- struct MultipartTestCase: Decodable {
- let description: String
- let boundary: String
- var quotedBoundary: Bool?
- let rawBody: String
- let expected: Expected
- }
-
- struct MultipartErrorTestCase: Decodable {
- let description: String
- var contentType: String?
- var body: String?
- var boundary: String?
- var rawBody: String?
- let expected: Expected
- }
-}
-
-// MARK: - Fixture Loading
-
-private func fixtureURL(_ name: String) -> URL {
- Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "http")!
-}
-
-private func loadFixture(_ name: String) throws -> T {
- let data = try Data(contentsOf: fixtureURL(name))
- return try JSONDecoder().decode(T.self, from: data)
-}
-
-// MARK: - Header Value Fixture Tests
-
-@Suite("Header Value Fixtures")
-struct HeaderValueFixtureTests {
-
- @Test("All fixture cases pass", arguments: try! loadHeaderValueTests())
- func fixtureCase(_ testCase: HeaderValueFixtures.HeaderValueTestCase) {
- let result = HeaderValue.extractParameter(testCase.parameter, from: testCase.headerValue)
- #expect(result == testCase.expected, "\(testCase.description)")
- }
-}
-
-private func loadHeaderValueTests() throws -> [HeaderValueFixtures.HeaderValueTestCase] {
- let fixtures: HeaderValueFixtures = try loadFixture("header-value-parsing")
- return fixtures.tests
-}
-
-extension HeaderValueFixtures.HeaderValueTestCase: CustomTestStringConvertible {
- var testDescription: String { description }
-}
-
-// MARK: - Request Parsing Fixture Tests
-
-@Suite("Request Parsing Fixtures")
-struct RequestParsingFixtureTests {
-
- @Test("All basic parsing cases pass", arguments: try! loadRequestTests())
- func basicCase(_ testCase: RequestParsingFixtures.RequestTestCase) throws {
- let raw = testCase.input
- let parser: HTTPRequestParser
- if let maxBodySize = testCase.maxBodySize {
- parser = HTTPRequestParser(maxBodySize: maxBodySize)
- parser.append(Data(raw.utf8))
- } else {
- parser = HTTPRequestParser(raw)
- }
-
- if let extra = testCase.appendAfterComplete {
- parser.append(Data(extra.utf8))
- }
-
- let exp = testCase.expected
-
- if exp.isComplete == false && exp.hasHeaders == false {
- #expect(!parser.state.hasHeaders)
- #expect(try parser.parseRequest() == nil)
- return
- }
-
- let request = try #require(try parser.parseRequest())
-
- if let method = exp.method {
- #expect(request.method == method, "\(testCase.description): method")
- }
- if let target = exp.target {
- #expect(request.target == target, "\(testCase.description): target")
- }
- if let isComplete = exp.isComplete {
- if isComplete {
- #expect(parser.state.isComplete, "\(testCase.description): isComplete")
- }
- }
- if let headers = exp.headers {
- for (key, value) in headers {
- #expect(request.header(key) == value, "\(testCase.description): header \(key)")
- }
- }
- if exp.hasBodyExpectation {
- if let expectedBody = exp.body {
- let requestBody = try #require(request.body)
- #expect(try readAll(requestBody) == Data(expectedBody.utf8), "\(testCase.description): body content")
- } else {
- #expect(request.body == nil, "\(testCase.description): body should be nil")
- }
- }
- }
-
- @Test("All error cases pass", arguments: try! loadRequestErrorTests())
- func errorCase(_ testCase: RequestParsingFixtures.RequestErrorTestCase) {
- let parser: HTTPRequestParser
-
- if let base64 = testCase.inputBase64 {
- let data = Data(base64Encoded: base64)!
- if let maxBodySize = testCase.maxBodySize {
- parser = HTTPRequestParser(maxBodySize: maxBodySize)
- } else {
- parser = HTTPRequestParser()
- }
- parser.append(data)
- } else {
- let raw = testCase.input!
- if let maxBodySize = testCase.maxBodySize {
- parser = HTTPRequestParser(maxBodySize: maxBodySize)
- parser.append(Data(raw.utf8))
- } else {
- parser = HTTPRequestParser(raw)
- }
- }
-
- let expectedError = testCase.expected.error
- do {
- _ = try parser.parseRequest()
- // A recoverable error is surfaced via `parseError` instead of thrown.
- if let parseError = parser.parseError {
- let errorName = String(describing: parseError)
- #expect(errorName == expectedError, "\(testCase.description): expected \(expectedError) but got \(errorName)")
- #expect(parseError.disposition == .recoverable, "\(testCase.description): \(errorName) surfaced via parseError but is not recoverable")
- } else {
- Issue.record("Expected error \(expectedError) but parsing succeeded — \(testCase.description)")
- }
- } catch {
- let errorName = String(describing: error)
- #expect(errorName == expectedError, "\(testCase.description): expected \(expectedError) but got \(errorName)")
- #expect((error as? HTTPRequestParseError)?.disposition == .fatal, "\(testCase.description): \(errorName) was thrown but is not fatal")
- }
- }
-
- @Test("All incremental cases pass", arguments: try! loadIncrementalTests())
- func incrementalCase(_ testCase: RequestParsingFixtures.IncrementalTestCase) throws {
- let parser = HTTPRequestParser()
-
- if let input = testCase.input, let chunkSize = testCase.chunkSize {
- let raw = input
- let data = Data(raw.utf8)
- for i in stride(from: 0, to: data.count, by: chunkSize) {
- let end = min(i + chunkSize, data.count)
- parser.append(data[i.. [RequestParsingFixtures.RequestTestCase] {
- let fixtures: RequestParsingFixtures = try loadFixture("request-parsing")
- return fixtures.tests
-}
-
-private func loadRequestErrorTests() throws -> [RequestParsingFixtures.RequestErrorTestCase] {
- let fixtures: RequestParsingFixtures = try loadFixture("request-parsing")
- return fixtures.errorTests
-}
-
-private func loadIncrementalTests() throws -> [RequestParsingFixtures.IncrementalTestCase] {
- let fixtures: RequestParsingFixtures = try loadFixture("request-parsing")
- return fixtures.incrementalTests
-}
-
-extension RequestParsingFixtures.RequestTestCase: CustomTestStringConvertible {
- var testDescription: String { description }
-}
-
-extension RequestParsingFixtures.RequestErrorTestCase: CustomTestStringConvertible {
- var testDescription: String { description }
-}
-
-extension RequestParsingFixtures.IncrementalTestCase: CustomTestStringConvertible {
- var testDescription: String { description }
-}
-
-// MARK: - Multipart Parsing Fixture Tests
-
-@Suite("Multipart Parsing Fixtures")
-struct MultipartParsingFixtureTests {
-
- @Test("All cases pass", arguments: try! loadMultipartTests())
- func testCase(_ testCase: MultipartFixtures.MultipartTestCase) throws {
- let request = try buildRawMultipartRequest(
- body: testCase.rawBody,
- boundary: testCase.boundary,
- quotedBoundary: testCase.quotedBoundary ?? false
- )
-
- if let expectedCT = testCase.expected.contentType {
- #expect(request.header("Content-Type") == expectedCT, "\(testCase.description): Content-Type")
- }
-
- let expectedParts = testCase.expected.parts ?? []
- let parts = try request.multipartParts()
- #expect(parts.count == expectedParts.count, "\(testCase.description): part count")
-
- for (i, expectedPart) in expectedParts.enumerated() where i < parts.count {
- #expect(parts[i].name == expectedPart.name, "\(testCase.description): part[\(i)].name")
- if expectedPart.filenameSpecified {
- #expect(parts[i].filename == expectedPart.filename, "\(testCase.description): part[\(i)].filename")
- }
- if let ct = expectedPart.contentType {
- #expect(parts[i].contentType == ct, "\(testCase.description): part[\(i)].contentType")
- }
- if let body = expectedPart.body {
- #expect(try readAll(parts[i].body) == Data(body.utf8), "\(testCase.description): part[\(i)].body")
- }
- }
- }
-
- @Test("All error cases pass", arguments: try! loadMultipartErrorTests())
- func errorCase(_ testCase: MultipartFixtures.MultipartErrorTestCase) throws {
- let request: ParsedHTTPRequest
-
- let contentType = testCase.contentType ?? testCase.expected.contentType
-
- if let rawBody = testCase.rawBody, let boundary = testCase.boundary {
- request = try buildRawMultipartRequest(body: rawBody, boundary: boundary)
- } else if let contentType, let body = testCase.body {
- let raw = "POST /wp/v2/posts HTTP/1.1\r\nHost: localhost\r\nContent-Type: \(contentType)\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)"
- let parser = HTTPRequestParser(raw)
- request = try #require(try parser.parseRequest())
- } else if let contentType {
- let raw = "GET /upload HTTP/1.1\r\nHost: localhost\r\nContent-Type: \(contentType)\r\n\r\n"
- let parser = HTTPRequestParser(raw)
- request = try #require(try parser.parseRequest())
- } else {
- Issue.record("Invalid error test case: \(testCase.description)")
- return
- }
-
- let expectedError = testCase.expected.error!
- do {
- _ = try request.multipartParts()
- Issue.record("Expected error \(expectedError) but succeeded — \(testCase.description)")
- } catch {
- let errorName = String(describing: error)
- #expect(errorName == expectedError, "\(testCase.description): expected \(expectedError) but got \(errorName)")
- }
- }
-}
-
-private func loadMultipartTests() throws -> [MultipartFixtures.MultipartTestCase] {
- let fixtures: MultipartFixtures = try loadFixture("multipart-parsing")
- return fixtures.tests
-}
-
-private func loadMultipartErrorTests() throws -> [MultipartFixtures.MultipartErrorTestCase] {
- let fixtures: MultipartFixtures = try loadFixture("multipart-parsing")
- return fixtures.errorTests
-}
-
-extension MultipartFixtures.MultipartTestCase: CustomTestStringConvertible {
- var testDescription: String { description }
-}
-
-extension MultipartFixtures.MultipartErrorTestCase: CustomTestStringConvertible {
- var testDescription: String { description }
-}
-
-private func buildRawMultipartRequest(
- body: String,
- boundary: String,
- quotedBoundary: Bool = false
-) throws -> ParsedHTTPRequest {
- let boundaryParam = quotedBoundary ? "\"\(boundary)\"" : boundary
- let raw = "POST /wp/v2/media HTTP/1.1\r\nHost: localhost\r\nContent-Type: multipart/form-data; boundary=\(boundaryParam)\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)"
- let parser = HTTPRequestParser(raw)
- return try #require(try parser.parseRequest())
-}
diff --git a/ios/Tests/GutenbergKitHTTPTests/HTTPRequestParserTests.swift b/ios/Tests/GutenbergKitHTTPTests/HTTPRequestParserTests.swift
deleted file mode 100644
index 43b255352..000000000
--- a/ios/Tests/GutenbergKitHTTPTests/HTTPRequestParserTests.swift
+++ /dev/null
@@ -1,549 +0,0 @@
-import Foundation
-import Testing
-@testable import GutenbergKitHTTP
-
-@Suite("HTTPRequestParser")
-struct HTTPRequestParserTests {
-
- // MARK: - Error Disposition
-
- /// Locks the fatal/recoverable classification so a refactor can't silently
- /// make a smuggling-relevant error recoverable — which would let a malformed
- /// request reach the handler before auth.
- @Test("only payloadTooLarge is recoverable")
- func onlyPayloadTooLargeIsRecoverable() {
- let recoverable = HTTPRequestParseError.allCases.filter { $0.disposition == .recoverable }
- #expect(recoverable == [.payloadTooLarge])
- }
-
- // MARK: - Basic Request Parsing
-
- @Test("parses a simple GET request")
- func parsesSimpleGet() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nHost: localhost:8080\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(parser.state.isComplete)
- #expect(request.method == "GET")
- #expect(request.target == "/wp/v2/posts")
- #expect(request.body == nil)
- }
-
- @Test("parses request target with query string")
- func parsesQueryString() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts?per_page=10&status=publish HTTP/1.1\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.target == "/wp/v2/posts?per_page=10&status=publish")
- }
-
- @Test("parses POST request with JSON body")
- func parsesPostWithBody() throws {
- let body = #"{"title":"Hello","content":"World"}"#
- let parser = HTTPRequestParser("POST /wp/v2/posts HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)")
- let request = try #require(try parser.parseRequest())
-
- #expect(parser.state.isComplete)
- #expect(request.method == "POST")
- #expect(request.target == "/wp/v2/posts")
- #expect(request.header("Content-Type") == "application/json")
-
- let requestBody = try #require(request.body)
- #expect(try readAll(requestBody) == Data(body.utf8))
- }
-
- @Test("parses DELETE request")
- func parsesDelete() throws {
- let parser = HTTPRequestParser("DELETE /wp/v2/posts/42 HTTP/1.1\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.method == "DELETE")
- #expect(request.target == "/wp/v2/posts/42")
- }
-
- @Test("parses PUT request with body")
- func parsesPutWithBody() throws {
- let body = #"{"title":"Updated"}"#
- let parser = HTTPRequestParser("PUT /wp/v2/posts/42 HTTP/1.1\r\nHost: localhost\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.method == "PUT")
- let requestBody = try #require(request.body)
- #expect(try readAll(requestBody) == Data(body.utf8))
- }
-
- // MARK: - Header Parsing
-
- @Test("parses multiple headers")
- func parsesMultipleHeaders() throws {
- let parser = HTTPRequestParser("GET / HTTP/1.1\r\nHost: localhost\r\nAccept: application/json\r\nAuthorization: Bearer token123\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.headers["Host"] == "localhost")
- #expect(request.headers["Accept"] == "application/json")
- #expect(request.headers["Authorization"] == "Bearer token123")
- }
-
- @Test("header lookup is case-insensitive")
- func headerLookupCaseInsensitive() throws {
- let parser = HTTPRequestParser("GET / HTTP/1.1\r\nHost: localhost\r\nContent-Type: text/html\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("content-type") == "text/html")
- #expect(request.header("CONTENT-TYPE") == "text/html")
- #expect(request.header("Content-Type") == "text/html")
- }
-
- @Test("parses header values containing colons")
- func parsesHeaderValuesWithColons() throws {
- let parser = HTTPRequestParser("GET / HTTP/1.1\r\nHost: localhost\r\nAuthorization: Basic dXNlcjpwYXNz\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Authorization") == "Basic dXNlcjpwYXNz")
- }
-
- // MARK: - Incremental Parsing
-
- @Test("handles data arriving in chunks")
- func handlesIncrementalData() throws {
- let raw = "GET /wp/v2/posts HTTP/1.1\r\nHost: localhost\r\n\r\n"
- let data = Data(raw.utf8)
-
- let parser = HTTPRequestParser()
-
- // Feed data byte by byte
- for i in 0.. 0 {
- let size = min(chunkSize, remaining)
- parser.append(Data(repeating: 0x42, count: size))
- remaining -= size
- }
-
- #expect(parser.state.isComplete)
- let request = try #require(try parser.parseRequest())
- #expect(request.method == "POST")
- #expect(!request.isComplete)
- #expect(parser.parseError == .payloadTooLarge)
- }
-
- @Test("drain mode does not buffer body bytes")
- func drainDoesNotBuffer() throws {
- let parser = HTTPRequestParser(maxBodySize: 10)
- let headers = "POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Length: 1000\r\n\r\n"
- parser.append(Data(headers.utf8))
- #expect(parser.state == .draining)
-
- // Feed 1000 bytes of body data.
- parser.append(Data(repeating: 0x43, count: 1000))
- #expect(parser.state.isComplete)
-
- // parseRequest() returns headers; error is on parseError.
- let request = try #require(try parser.parseRequest())
- #expect(request.method == "POST")
- #expect(!request.isComplete)
- #expect(parser.parseError == .payloadTooLarge)
- }
-
- @Test("rejects headers that exceed maxHeaderSize without terminator")
- func rejectsOversizedHeaders() {
- let parser = HTTPRequestParser()
- // Send more than 64KB of header data without \r\n\r\n
- let longHeader = "X-Padding: " + String(repeating: "A", count: 1000) + "\r\n"
- let headerCount = (HTTPRequestParser.maxHeaderSize / longHeader.utf8.count) + 1
- var raw = "GET / HTTP/1.1\r\n"
- for _ in 0.. String {
- let connection = NWConnection(
- host: .ipv4(.loopback),
- port: NWEndpoint.Port(rawValue: port)!,
- using: .tcp
- )
- defer { connection.cancel() }
-
- try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in
- connection.stateUpdateHandler = { state in
- switch state {
- case .ready:
- connection.stateUpdateHandler = nil
- cont.resume()
- case .failed(let error):
- connection.stateUpdateHandler = nil
- cont.resume(throwing: error)
- default:
- break
- }
- }
- connection.start(queue: .global())
- }
-
- try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in
- connection.send(content: Data(request.utf8), completion: .contentProcessed { error in
- if let error { cont.resume(throwing: error) } else { cont.resume() }
- })
- }
-
- return try await withCheckedThrowingContinuation { cont in
- connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { data, _, _, error in
- if let error {
- cont.resume(throwing: error)
- } else {
- cont.resume(returning: String(data: data ?? Data(), encoding: .utf8) ?? "")
- }
- }
- }
- }
-}
-
-#endif // canImport(Network)
diff --git a/ios/Tests/GutenbergKitHTTPTests/HTTPServerCancellationTests.swift b/ios/Tests/GutenbergKitHTTPTests/HTTPServerCancellationTests.swift
deleted file mode 100644
index 817abdc97..000000000
--- a/ios/Tests/GutenbergKitHTTPTests/HTTPServerCancellationTests.swift
+++ /dev/null
@@ -1,292 +0,0 @@
-#if canImport(Network)
-
-import Foundation
-import Network
-import Testing
-@testable import GutenbergKitHTTP
-
-/// Covers the "connection close cancels the in-flight handler" behaviour. Once a
-/// request has been fully read, no bytes flow on the connection until the
-/// response is sent, so a handler awaiting slow outbound work (the media-upload
-/// relay awaiting `POST /wp/v2/media`) leaves the connection idle. If the peer
-/// closes it during that window — what the editor WebView does when it aborts an
-/// upload — the handler's task must be cancelled so the outbound work is torn
-/// down instead of running to completion and orphaning an attachment.
-@Suite("HTTPServer Cancellation")
-struct HTTPServerCancellationTests {
-
- @Test("peer closing the connection cancels an in-flight handler")
- func peerCloseCancelsHandler() async throws {
- let signals = HandlerSignals()
-
- let server = try await HTTPServer.start(
- name: "cancel-on-close",
- requiresAuthentication: true
- ) { _ in
- signals.markStarted()
- do {
- // Stands in for slow outbound work. A cancelled task throws here
- // promptly, well before the sleep would otherwise complete.
- try await Task.sleep(for: .seconds(10))
- signals.markFinishedNormally()
- } catch {
- signals.markCancelled()
- }
- return HTTPResponse(status: 200, body: Data("OK\n".utf8))
- }
- defer { server.stop() }
-
- // Open a raw connection and send a complete request so the handler runs.
- let connection = NWConnection(
- host: .ipv4(.loopback),
- port: NWEndpoint.Port(rawValue: server.port)!,
- using: .tcp
- )
- try await waitUntilReady(connection)
- let request = "POST /upload HTTP/1.1\r\nHost: 127.0.0.1\r\nProxy-Authorization: Bearer \(server.token)\r\nContent-Length: 0\r\n\r\n"
- try await send(Data(request.utf8), on: connection)
-
- // Once the handler is actually running, abort by closing the client side
- // of the connection — exactly what an aborted `fetch` does.
- try await signals.waitUntilStarted()
- connection.cancel()
-
- // The handler must observe cancellation promptly, not run its 10s sleep
- // to completion.
- let cancelled = await signals.waitUntilCancelled(timeout: .seconds(3))
- #expect(cancelled)
- #expect(!signals.didFinishNormally)
- }
-
- @Test("a client write-half-close mid-handler is treated as an abort, and the response is dropped")
- func halfCloseIsTreatedAsAbort() async throws {
- // A client MAY legally half-close its write half (`shutdown(SHUT_WR)`)
- // after a complete request, keeping its read half open for the response.
- // The server sees the same read EOF a full close produces and can't tell
- // the two apart, so — by design — it treats the half-close as an abort: it
- // cancels the in-flight handler and sends no response. This pins that
- // deliberate tradeoff (prompt cancellation of the outbound POST /wp/v2/media
- // so an aborted upload can't orphan an attachment) against a future change
- // that "fixes" the half-close and, with it, silently resurrects the orphan
- // bug. Safe in practice: the only client is the editor WebView's `fetch`,
- // which never half-closes and fully closes on abort.
- let signals = HandlerSignals()
-
- let server = try await HTTPServer.start(
- name: "half-close-abort",
- requiresAuthentication: true
- ) { _ in
- signals.markStarted()
- do {
- try await Task.sleep(for: .seconds(10))
- signals.markFinishedNormally()
- } catch {
- signals.markCancelled()
- }
- return HTTPResponse(status: 200, body: Data("OK\n".utf8))
- }
- defer { server.stop() }
-
- let connection = NWConnection(
- host: .ipv4(.loopback),
- port: NWEndpoint.Port(rawValue: server.port)!,
- using: .tcp
- )
- try await waitUntilReady(connection)
- let request = "POST /upload HTTP/1.1\r\nHost: 127.0.0.1\r\nProxy-Authorization: Bearer \(server.token)\r\nContent-Length: 0\r\n\r\n"
- try await send(Data(request.utf8), on: connection)
-
- // Once the handler is running, half-close the write half only — the read
- // half stays open to receive the (never-sent) response.
- try await signals.waitUntilStarted()
- try await halfCloseWrite(connection)
-
- // Treated exactly like a full close: handler cancelled, no response.
- let cancelled = await signals.waitUntilCancelled(timeout: .seconds(3))
- #expect(cancelled)
- #expect(!signals.didFinishNormally)
-
- let received = (try? await receiveResponse(connection)) ?? ""
- #expect(!received.hasPrefix("HTTP/1.1"))
- connection.cancel()
- }
-
- @Test("stopping the server mid-handler closes the connection without sending a response")
- func serverStopMidHandlerSendsNoResponse() async throws {
- let signals = HandlerSignals()
-
- let server = try await HTTPServer.start(
- name: "cancel-on-stop",
- requiresAuthentication: true
- ) { _ in
- signals.markStarted()
- do {
- try await Task.sleep(for: .seconds(10))
- } catch {
- signals.markCancelled()
- }
- // The handler is non-throwing, so it still returns a response after
- // being cancelled — the server must NOT write it to the dying
- // connection.
- return HTTPResponse(status: 200, body: Data("OK\n".utf8))
- }
-
- let connection = NWConnection(
- host: .ipv4(.loopback),
- port: NWEndpoint.Port(rawValue: server.port)!,
- using: .tcp
- )
- try await waitUntilReady(connection)
- let request = "POST /upload HTTP/1.1\r\nHost: 127.0.0.1\r\nProxy-Authorization: Bearer \(server.token)\r\nContent-Length: 0\r\n\r\n"
- try await send(Data(request.utf8), on: connection)
-
- // Once the handler is running, stop the server — this cancels the
- // in-flight connection task.
- try await signals.waitUntilStarted()
- server.stop()
-
- // The client must see the connection close (EOF/reset), not an HTTP
- // response written to a connection being torn down.
- let received = (try? await receiveResponse(connection)) ?? ""
- #expect(!received.hasPrefix("HTTP/1.1"))
- connection.cancel()
- }
-
- @Test("handler that finishes first still sends its response despite the watcher")
- func handlerFinishesFirstStillResponds() async throws {
- // The close watcher must not interfere with the normal path: a handler
- // that completes before any close still produces a response on the live
- // connection.
- let server = try await HTTPServer.start(
- name: "no-close-normal-response",
- requiresAuthentication: true
- ) { _ in
- HTTPResponse(status: 200, body: Data("OK\n".utf8))
- }
- defer { server.stop() }
-
- let request = "POST /upload HTTP/1.1\r\nHost: 127.0.0.1\r\nProxy-Authorization: Bearer \(server.token)\r\nContent-Length: 0\r\n\r\n"
- let response = try await sendRaw(request, toPort: server.port)
- #expect(response.hasPrefix("HTTP/1.1 200"))
- }
-
- // MARK: - Helpers
-
- private func waitUntilReady(_ connection: NWConnection) async throws {
- try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in
- connection.stateUpdateHandler = { state in
- switch state {
- case .ready:
- connection.stateUpdateHandler = nil
- cont.resume()
- case .failed(let error):
- connection.stateUpdateHandler = nil
- cont.resume(throwing: error)
- default:
- break
- }
- }
- connection.start(queue: .global())
- }
- }
-
- private func send(_ data: Data, on connection: NWConnection) async throws {
- try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in
- connection.send(content: data, completion: .contentProcessed { error in
- if let error { cont.resume(throwing: error) } else { cont.resume() }
- })
- }
- }
-
- /// Half-closes the connection's send half (a FIN via an empty final message)
- /// while leaving the receive half open — Network.framework's `shutdown(SHUT_WR)`.
- private func halfCloseWrite(_ connection: NWConnection) async throws {
- try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in
- connection.send(
- content: nil,
- contentContext: .finalMessage,
- isComplete: true,
- completion: .contentProcessed { error in
- if let error { cont.resume(throwing: error) } else { cont.resume() }
- }
- )
- }
- }
-
- /// Reads one chunk from the connection, returning it as a string (empty on a
- /// clean EOF). Throws if the connection errors (e.g. reset).
- private func receiveResponse(_ connection: NWConnection) async throws -> String {
- try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in
- connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { data, _, _, error in
- if let error {
- cont.resume(throwing: error)
- } else {
- cont.resume(returning: String(data: data ?? Data(), encoding: .utf8) ?? "")
- }
- }
- }
- }
-
- private func sendRaw(_ request: String, toPort port: UInt16) async throws -> String {
- let connection = NWConnection(
- host: .ipv4(.loopback),
- port: NWEndpoint.Port(rawValue: port)!,
- using: .tcp
- )
- defer { connection.cancel() }
- try await waitUntilReady(connection)
- try await send(Data(request.utf8), on: connection)
- return try await withCheckedThrowingContinuation { cont in
- connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { data, _, _, error in
- if let error {
- cont.resume(throwing: error)
- } else {
- cont.resume(returning: String(data: data ?? Data(), encoding: .utf8) ?? "")
- }
- }
- }
- }
-}
-
-/// Thread-safe coordinator letting a test observe when the server's handler
-/// starts and whether it observed cancellation.
-private final class HandlerSignals: @unchecked Sendable {
- private let lock = NSLock()
- private var started = false
- private var cancelled = false
- private var finishedNormally = false
-
- func markStarted() { lock.lock(); started = true; lock.unlock() }
- func markCancelled() { lock.lock(); cancelled = true; lock.unlock() }
- func markFinishedNormally() { lock.lock(); finishedNormally = true; lock.unlock() }
-
- private var isStarted: Bool { lock.lock(); defer { lock.unlock() }; return started }
- private var isCancelled: Bool { lock.lock(); defer { lock.unlock() }; return cancelled }
- var didFinishNormally: Bool { lock.lock(); defer { lock.unlock() }; return finishedNormally }
-
- func waitUntilStarted(timeout: Duration = .seconds(3)) async throws {
- let clock = ContinuousClock()
- let deadline = clock.now + timeout
- while clock.now < deadline {
- if isStarted { return }
- try await Task.sleep(for: .milliseconds(20))
- }
- throw HandlerSignalTimeout.handlerNeverStarted
- }
-
- func waitUntilCancelled(timeout: Duration) async -> Bool {
- let clock = ContinuousClock()
- let deadline = clock.now + timeout
- while clock.now < deadline {
- if isCancelled { return true }
- try? await Task.sleep(for: .milliseconds(20))
- }
- return isCancelled
- }
-}
-
-private enum HandlerSignalTimeout: Error {
- case handlerNeverStarted
-}
-
-#endif // canImport(Network)
diff --git a/ios/Tests/GutenbergKitHTTPTests/HTTPServerStartTests.swift b/ios/Tests/GutenbergKitHTTPTests/HTTPServerStartTests.swift
deleted file mode 100644
index fc86f22a9..000000000
--- a/ios/Tests/GutenbergKitHTTPTests/HTTPServerStartTests.swift
+++ /dev/null
@@ -1,73 +0,0 @@
-#if canImport(Network)
-
-import Foundation
-import Network
-import Testing
-@testable import GutenbergKitHTTP
-
-@Suite("HTTPServer Start")
-struct HTTPServerStartTests {
-
- // The unit-level readiness-wait timeout behavior (a listener stuck in a
- // non-terminal state must not hang the caller) is covered by
- // `HTTPServerTimeoutTests` against `HTTPServer.withStartTimeout`. This suite
- // keeps the end-to-end port-conflict check below.
-
- @Test("start fails promptly when the port is already taken (no hang)")
- func startFailsPromptlyOnPortConflict() async throws {
- let first = try await HTTPServer.start(name: "start-conflict-test-a") { _ in
- HTTPResponse(status: 200)
- }
- defer { first.stop() }
-
- let clock = ContinuousClock()
- let started = clock.now
- await #expect(throws: HTTPServerError.self) {
- _ = try await HTTPServer.start(
- name: "start-conflict-test-b",
- port: first.port,
- startTimeout: .milliseconds(500)
- ) { _ in
- HTTPResponse(status: 200)
- }
- }
- let elapsed = clock.now - started
-
- // Whether the conflict surfaces as an immediate `.failed` or parks the
- // listener in `.waiting`, start() must give up within the bounded wait
- // rather than suspending its caller indefinitely.
- #expect(elapsed < .seconds(5))
- }
-
- @Test("serves requests from an HTTPRequestHandler object, carrying its state")
- func servesFromRequestHandlerObject() async throws {
- // The point of the object overload: the handler holds its dependencies as
- // stored properties and serves from an instance method, so a consumer with
- // state doesn't need statics threading a context through every call.
- let server = try await HTTPServer.start(
- name: "handler-object-test",
- requiresAuthentication: false,
- handler: EchoHandler(greeting: "hello from a struct")
- )
- defer { server.stop() }
-
- let url = URL(string: "http://127.0.0.1:\(server.port)/anything")!
- let (data, response) = try await URLSession.shared.data(from: url)
-
- #expect((response as? HTTPURLResponse)?.statusCode == 200)
- #expect(String(decoding: data, as: UTF8.self) == "hello from a struct")
- }
-}
-
-/// A value-type handler, holding only a `String` — so it has no strong edge back to
-/// whatever owns the server. ``HTTPRequestHandler`` isn't `AnyObject`-constrained so a
-/// handler *can* take this shape; a `struct` storing the owner would still cycle.
-private struct EchoHandler: HTTPRequestHandler {
- let greeting: String
-
- func handle(_ request: HTTPServer.Request) async -> HTTPResponse {
- HTTPResponse(status: 200, body: Data(greeting.utf8))
- }
-}
-
-#endif
diff --git a/ios/Tests/GutenbergKitHTTPTests/HTTPServerTimeoutTests.swift b/ios/Tests/GutenbergKitHTTPTests/HTTPServerTimeoutTests.swift
deleted file mode 100644
index 4fc444480..000000000
--- a/ios/Tests/GutenbergKitHTTPTests/HTTPServerTimeoutTests.swift
+++ /dev/null
@@ -1,277 +0,0 @@
-#if canImport(Network)
-
-import Foundation
-import Network
-import Testing
-@testable import GutenbergKitHTTP
-
-/// Covers the split read-timeout model: the pre-body phase (headers + drain) is
-/// bounded by `readTimeout`, while an accepted body is bounded by the generous
-/// `bodyReadTimeout` plus the per-read `idleTimeout`. Also covers rejecting an
-/// auth-exempt `OPTIONS` request that carries a body.
-@Suite("HTTPServer Timeouts")
-struct HTTPServerTimeoutTests {
-
- @Test("body that streams steadily past readTimeout still succeeds")
- func steadyBodyPastReadTimeoutSucceeds() async throws {
- // Pre-body cap is short; the body ceiling and idle timeout are generous.
- // A body streamed over a span longer than `readTimeout` (but with no gap
- // longer than `idleTimeout`) must complete — the pre-body cap must not
- // bound the accepted body.
- let server = try await HTTPServer.start(
- name: "timeout-steady-body",
- requiresAuthentication: true,
- readTimeout: .milliseconds(500),
- bodyReadTimeout: .seconds(20),
- idleTimeout: .seconds(5)
- ) { _ in
- HTTPResponse(status: 200, body: Data("OK\n".utf8))
- }
- defer { server.stop() }
-
- // Five 4-byte chunks, 200 ms apart → ~1s of body transfer, well past the
- // 500 ms pre-body cap, with each gap far under the 5s idle timeout.
- let chunks = Array(repeating: Data("data".utf8), count: 5)
- let contentLength = chunks.reduce(0) { $0 + $1.count }
- let header = "POST /test HTTP/1.1\r\nHost: 127.0.0.1\r\nProxy-Authorization: Bearer \(server.token)\r\nContent-Length: \(contentLength)\r\n\r\n"
-
- let response = try await sendChunked(header, chunks: chunks, gap: .milliseconds(200), toPort: server.port)
- #expect(response.hasPrefix("HTTP/1.1 200"))
- }
-
- @Test("body stalled beyond idleTimeout is reaped promptly despite a generous ceiling")
- func stalledBodyIsReaped() async throws {
- // `readTimeout` and `bodyReadTimeout` are long, so only the idle timeout
- // can end this connection. A body that stops mid-transfer must still be
- // reaped promptly by the idle guard rather than held for the full ceiling.
- //
- // iOS closes the connection on read timeout rather than delivering a 408
- // (see RFC9110ConformanceTests.serverSends408OnReadTimeout, disabled for
- // the same reason: "HTTPServer does not yet send 408 on idle timeout").
- // The property under test is the reaping, observed as a prompt close.
- let server = try await HTTPServer.start(
- name: "timeout-stalled-body",
- requiresAuthentication: true,
- readTimeout: .seconds(10),
- bodyReadTimeout: .seconds(10),
- idleTimeout: .milliseconds(500)
- ) { _ in
- HTTPResponse(status: 200, body: Data("OK\n".utf8))
- }
- defer { server.stop() }
-
- // Declare 100 bytes but send only 10, then stop. The server waits one idle
- // interval for more body bytes, gets none, and closes the connection.
- let header = "POST /test HTTP/1.1\r\nHost: 127.0.0.1\r\nProxy-Authorization: Bearer \(server.token)\r\nContent-Length: 100\r\n\r\n"
- let clock = ContinuousClock()
- let start = clock.now
- let response = try await sendChunked(header, chunks: [Data(repeating: 0x61, count: 10)], gap: .zero, toPort: server.port)
- let elapsed = clock.now - start
-
- #expect(!response.contains("HTTP/1.1 200")) // the stalled body is not accepted
- #expect(elapsed < .seconds(3)) // reaped by the 500ms idle timeout, not the 10s ceiling
- }
-
- @Test("auth-exempt OPTIONS carrying a body is rejected with 400")
- func optionsWithBodyReturns400() async throws {
- let server = try await HTTPServer.start(
- name: "options-with-body",
- requiresAuthentication: true
- ) { _ in
- HTTPResponse(status: 200, body: Data("OK\n".utf8))
- }
- defer { server.stop() }
-
- // A real CORS preflight is bodyless; an OPTIONS with a body must not be
- // read/drained on the auth-exempt path.
- let raw = "OPTIONS /test HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 5\r\n\r\nhello"
- let response = try await sendRaw(raw, toPort: server.port)
- #expect(response.hasPrefix("HTTP/1.1 400"))
- }
-
- @Test("auth-exempt OPTIONS with an oversized body is rejected with 400, not drained")
- func optionsWithOversizedBodyReturns400() async throws {
- let server = try await HTTPServer.start(
- name: "options-oversized-body",
- requiresAuthentication: true,
- maxRequestBodySize: 16
- ) { _ in
- HTTPResponse(status: 200, body: Data("OK\n".utf8))
- }
- defer { server.stop() }
-
- // Content-Length exceeds the max body size, so the parser would otherwise
- // enter the drain path — the OPTIONS-with-body guard must reject it first.
- let raw = "OPTIONS /test HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 1000\r\n\r\n"
- let response = try await sendRaw(raw, toPort: server.port)
- #expect(response.hasPrefix("HTTP/1.1 400"))
- }
-
- @Test("bodyless OPTIONS preflight still succeeds")
- func bodylessOptionsSucceeds() async throws {
- let server = try await HTTPServer.start(
- name: "options-bodyless",
- requiresAuthentication: true
- ) { _ in
- HTTPResponse(status: 200, body: Data("OK\n".utf8))
- }
- defer { server.stop() }
-
- let raw = "OPTIONS /test HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"
- let response = try await sendRaw(raw, toPort: server.port)
- #expect(response.hasPrefix("HTTP/1.1 200"))
- }
-
- // MARK: - Start timeout
-
- @Test("the start-readiness wait fails with .startTimeout instead of hanging")
- func startTimeoutFiresOnStuckBind() async {
- // `withStartTimeout` bounds `HTTPServer.start`'s wait for the listener to
- // become ready. If the readiness wait never completes (a listener stuck in
- // a non-terminal state), it must fail near the deadline — not hang the
- // caller (the editor load awaits this) for the whole operation.
- let clock = ContinuousClock()
- let start = clock.now
- do {
- _ = try await HTTPServer.withStartTimeout(.milliseconds(100)) {
- try await Task.sleep(for: .seconds(60)) // stands in for a stuck bind
- return 0
- }
- Issue.record("expected withStartTimeout to throw .startTimeout")
- } catch HTTPServerError.startTimeout {
- // Expected: the timeout won the race.
- } catch {
- Issue.record("expected .startTimeout, got \(error)")
- }
- #expect(clock.now - start < .seconds(2))
- }
-
- @Test("the start-readiness wait returns the bound server when it's ready in time")
- func startTimeoutPassesValueThroughWhenReady() async throws {
- // The common case: readiness completes well within the deadline, so the
- // timeout is inert and the operation's value flows through unchanged.
- let value = try await HTTPServer.withStartTimeout(.seconds(5)) { 42 }
- #expect(value == 42)
- }
-
- // MARK: - Recoverable parse errors
-
- @Test("a recoverable parse error is answered by the library and never reaches the handler")
- func recoverableErrorBypassesHandler() async throws {
- let handlerCalled = CallFlag()
- let server = try await HTTPServer.start(
- name: "recoverable-default",
- requiresAuthentication: false,
- maxRequestBodySize: 16
- ) { _ in
- handlerCalled.mark()
- return HTTPResponse(status: 200, body: Data("OK".utf8))
- }
- defer { server.stop() }
-
- // A 100-byte body far exceeds the 16-byte limit → payloadTooLarge, a
- // recoverable error. With no delegate, the library answers a generic 413.
- let header = "POST /test HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 100\r\n\r\n"
- let response = try await sendChunked(header, chunks: [Data(repeating: 0x61, count: 100)], gap: .zero, toPort: server.port)
-
- #expect(response.hasPrefix("HTTP/1.1 413"))
- #expect(!handlerCalled.wasCalled) // a rejected request must never reach the handler
- }
-
- @Test("a delegate customizes the recoverable-error response")
- func delegateCustomizesRecoverableError() async throws {
- let server = try await HTTPServer.start(
- name: "recoverable-delegate",
- requiresAuthentication: false,
- maxRequestBodySize: 16,
- delegate: CustomErrorDelegate()
- ) { _ in
- HTTPResponse(status: 200, body: Data("OK".utf8))
- }
- defer { server.stop() }
-
- let header = "POST /test HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 100\r\n\r\n"
- let response = try await sendChunked(header, chunks: [Data(repeating: 0x61, count: 100)], gap: .zero, toPort: server.port)
-
- #expect(response.hasPrefix("HTTP/1.1 413"))
- #expect(response.contains("custom-error-body")) // the delegate's body, not the generic one
- }
-
- // MARK: - Helpers
-
- /// Sends `header` then each element of `chunks`, pausing `gap` before every
- /// chunk, and returns the first response chunk. Used to simulate a body that
- /// arrives incrementally over time.
- private func sendChunked(_ header: String, chunks: [Data], gap: Duration, toPort port: UInt16) async throws -> String {
- let connection = NWConnection(
- host: .ipv4(.loopback),
- port: NWEndpoint.Port(rawValue: port)!,
- using: .tcp
- )
- defer { connection.cancel() }
-
- try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in
- connection.stateUpdateHandler = { state in
- switch state {
- case .ready:
- connection.stateUpdateHandler = nil
- cont.resume()
- case .failed(let error):
- connection.stateUpdateHandler = nil
- cont.resume(throwing: error)
- default:
- break
- }
- }
- connection.start(queue: .global())
- }
-
- try await send(Data(header.utf8), on: connection)
- for chunk in chunks {
- if gap != .zero {
- try await Task.sleep(for: gap)
- }
- try await send(chunk, on: connection)
- }
-
- return try await withCheckedThrowingContinuation { cont in
- connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { data, _, _, error in
- if let error {
- cont.resume(throwing: error)
- } else {
- cont.resume(returning: String(data: data ?? Data(), encoding: .utf8) ?? "")
- }
- }
- }
- }
-
- private func send(_ data: Data, on connection: NWConnection) async throws {
- try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in
- connection.send(content: data, completion: .contentProcessed { error in
- if let error { cont.resume(throwing: error) } else { cont.resume() }
- })
- }
- }
-
- /// Sends a raw HTTP request over TCP and returns the response string.
- private func sendRaw(_ request: String, toPort port: UInt16) async throws -> String {
- try await sendChunked(request, chunks: [], gap: .zero, toPort: port)
- }
-}
-
-/// A thread-safe one-way flag for asserting whether a handler ran.
-private final class CallFlag: @unchecked Sendable {
- private let lock = NSLock()
- private var value = false
- func mark() { lock.lock(); value = true; lock.unlock() }
- var wasCalled: Bool { lock.lock(); defer { lock.unlock() }; return value }
-}
-
-/// A delegate that returns a recognizable body for a recoverable parse error.
-private final class CustomErrorDelegate: HTTPServerDelegate {
- func response(forRecoverableParseError error: HTTPRequestParseError) -> HTTPResponse {
- HTTPResponse(status: error.httpStatus, body: Data("custom-error-body".utf8))
- }
-}
-
-#endif // canImport(Network)
diff --git a/ios/Tests/GutenbergKitHTTPTests/HeaderValueTests.swift b/ios/Tests/GutenbergKitHTTPTests/HeaderValueTests.swift
deleted file mode 100644
index eb46ded50..000000000
--- a/ios/Tests/GutenbergKitHTTPTests/HeaderValueTests.swift
+++ /dev/null
@@ -1,149 +0,0 @@
-import Foundation
-import Testing
-@testable import GutenbergKitHTTP
-
-@Suite("HeaderValue")
-struct HeaderValueTests {
-
- // MARK: - Unquoted Values
-
- @Test("Extracts unquoted parameter value")
- func unquotedValue() {
- let result = HeaderValue.extractParameter("boundary", from: "multipart/form-data; boundary=AaB03x")
- #expect(result == "AaB03x")
- }
-
- @Test("Unquoted value terminated by semicolon")
- func unquotedValueTerminatedBySemicolon() {
- let result = HeaderValue.extractParameter("boundary", from: "multipart/form-data; boundary=AaB03x; charset=utf-8")
- #expect(result == "AaB03x")
- }
-
- @Test("Unquoted value at end of string")
- func unquotedValueAtEnd() {
- let result = HeaderValue.extractParameter("charset", from: "text/plain; charset=utf-8")
- #expect(result == "utf-8")
- }
-
- // MARK: - Quoted Values
-
- @Test("Extracts quoted parameter value")
- func quotedValue() {
- let result = HeaderValue.extractParameter("name", from: "form-data; name=\"field1\"")
- #expect(result == "field1")
- }
-
- @Test("Empty quoted value")
- func emptyQuotedValue() {
- let result = HeaderValue.extractParameter("name", from: "form-data; name=\"\"")
- // Optional `String?`: `== ""` asserts present-and-empty, which `isEmpty` cannot express.
- // swiftlint:disable:next empty_string
- #expect(result == "")
- }
-
- @Test("Quoted value with spaces")
- func quotedValueWithSpaces() {
- let result = HeaderValue.extractParameter("name", from: "form-data; name=\"my field\"")
- #expect(result == "my field")
- }
-
- // MARK: - Backslash Escapes
-
- @Test("Quoted value with escaped quote")
- func escapedQuote() {
- let result = HeaderValue.extractParameter("name", from: #"form-data; name="field\"name""#)
- #expect(result == "field\"name")
- }
-
- @Test("Quoted value with escaped backslash")
- func escapedBackslash() {
- let result = HeaderValue.extractParameter("filename", from: #"form-data; filename="C:\\path\\file.txt""#)
- #expect(result == "C:\\path\\file.txt")
- }
-
- @Test("Quoted value with escaped single-quote")
- func escapedSingleQuote() {
- let result = HeaderValue.extractParameter("boundary", from: #"multipart/form-data; boundary="abc\'def""#)
- #expect(result == "abc'def")
- }
-
- // MARK: - Case Insensitivity
-
- @Test("Parameter name matching is case-insensitive")
- func caseInsensitive() {
- let result = HeaderValue.extractParameter("Boundary", from: "multipart/form-data; boundary=AaB03x")
- #expect(result == "AaB03x")
- }
-
- // MARK: - Skipping Quoted Strings
-
- @Test("Parameter inside another quoted value is not matched")
- func paramInsideQuotedValueSkipped() {
- let result = HeaderValue.extractParameter("name", from: #"form-data; dummy="name=evil"; name="real""#)
- #expect(result == "real")
- }
-
- @Test("boundary= inside quoted value is skipped")
- func boundaryInsideQuotedValueSkipped() {
- let result = HeaderValue.extractParameter("boundary", from: #"multipart/form-data; charset="boundary=fake"; boundary=RealBoundary"#)
- #expect(result == "RealBoundary")
- }
-
- // MARK: - Missing Parameters
-
- @Test("Missing parameter returns nil")
- func missingParameter() {
- let result = HeaderValue.extractParameter("filename", from: "form-data; name=\"field1\"")
- #expect(result == nil)
- }
-
- @Test("Empty header value returns nil")
- func emptyHeaderValue() {
- let result = HeaderValue.extractParameter("name", from: "")
- #expect(result == nil)
- }
-
- // MARK: - Multiple Parameters
-
- @Test("Extracts correct parameter when multiple are present")
- func multipleParameters() {
- let result = HeaderValue.extractParameter("filename", from: "form-data; name=\"file\"; filename=\"photo.jpg\"")
- #expect(result == "photo.jpg")
- }
-
- // MARK: - Substring Matching
-
- @Test("Does not match parameter name as substring of another parameter name")
- func noSubstringMatch() {
- // "name=" appears inside "filename=" — should not match it.
- let result = HeaderValue.extractParameter("name", from: #"form-data; filename="test.jpg"; name="real""#)
- #expect(result == "real")
- }
-
- // MARK: - Edge Cases
-
- @Test("Unterminated quoted value returns accumulated content")
- func unterminatedQuotedValue() {
- let result = HeaderValue.extractParameter("name", from: "form-data; name=\"unclosed")
- #expect(result == "unclosed")
- }
-
- @Test("Quoted value containing semicolons")
- func quotedValueWithSemicolons() {
- let result = HeaderValue.extractParameter("name", from: "form-data; name=\"a;b;c\"")
- #expect(result == "a;b;c")
- }
-
- @Test("No space after semicolon")
- func noSpaceAfterSemicolon() {
- let result = HeaderValue.extractParameter("name", from: "form-data;name=\"field1\"")
- #expect(result == "field1")
- }
-
- @Test("Backslash at end of quoted value")
- func backslashAtEndOfQuotedValue() {
- // Backslash with nothing after it — should stop extraction.
- let result = HeaderValue.extractParameter("name", from: #"form-data; name="trailing\"#)
- #expect(result == "trailing")
- }
-}
diff --git a/ios/Tests/GutenbergKitHTTPTests/ParsedHTTPRequestTests.swift b/ios/Tests/GutenbergKitHTTPTests/ParsedHTTPRequestTests.swift
deleted file mode 100644
index 82e17b2da..000000000
--- a/ios/Tests/GutenbergKitHTTPTests/ParsedHTTPRequestTests.swift
+++ /dev/null
@@ -1,345 +0,0 @@
-import Foundation
-import Testing
-@testable import GutenbergKitHTTP
-
-@Suite("ParsedHTTPRequest")
-struct ParsedHTTPRequestTests {
-
- // MARK: - path / query
-
- @Test(
- "path and query split the target",
- arguments: [
- ("/upload", "/upload", ""),
- ("/upload?_embed=wp:featuredmedia", "/upload", "?_embed=wp:featuredmedia"),
- // A bare "?" carries no parameters, so the query is empty.
- ("/upload?", "/upload", ""),
- ("/wp/v2/posts?per_page=10&page=2", "/wp/v2/posts", "?per_page=10&page=2"),
- // Only the first "?" separates path from query; later ones belong to it.
- ("/search?q=a?b", "/search", "?q=a?b"),
- ("/", "/", ""),
- ]
- )
- func pathAndQuery(target: String, expectedPath: String, expectedQuery: String) {
- let request = ParsedHTTPRequest.complete(
- method: "POST",
- target: target,
- httpVersion: "HTTP/1.1",
- headers: [:],
- body: nil
- )
-
- #expect(request.path == expectedPath)
- #expect(request.query == expectedQuery)
- }
-
- @Test("path and query are available on a partial request")
- func pathAndQueryOnPartial() {
- let request = ParsedHTTPRequest.partial(
- method: "POST",
- target: "/upload?_embed=wp:featuredmedia",
- httpVersion: "HTTP/1.1",
- headers: [:]
- )
-
- #expect(request.path == "/upload")
- #expect(request.query == "?_embed=wp:featuredmedia")
- }
-
- // MARK: - urlRequest(relativeTo:)
-
- @Test("urlRequest resolves path against base URL")
- func urlRequestResolvesPath() {
- let request = ParsedHTTPRequest.complete(
- method: "GET",
- target: "/wp/v2/posts?per_page=10",
- httpVersion: "HTTP/1.1",
- headers: ["Accept": "application/json"],
- body: nil
- )
-
- let baseURL = URL(string: "https://example.com/wp-json")!
- let urlRequest = request.urlRequest(relativeTo: baseURL)
-
- #expect(urlRequest != nil)
- #expect(urlRequest?.url?.absoluteString == "https://example.com/wp/v2/posts?per_page=10")
- #expect(urlRequest?.httpMethod == "GET")
- #expect(urlRequest?.value(forHTTPHeaderField: "Accept") == "application/json")
- }
-
- @Test("urlRequest includes body stream")
- func urlRequestIncludesBody() throws {
- let body = Data(#"{"title":"Test"}"#.utf8)
- let request = ParsedHTTPRequest.complete(
- method: "POST",
- target: "/wp/v2/posts",
- httpVersion: "HTTP/1.1",
- headers: ["Content-Type": "application/json"],
- body: RequestBody(data: body)
- )
-
- let baseURL = URL(string: "https://example.com/wp-json")!
- let urlRequest = request.urlRequest(relativeTo: baseURL)
-
- #expect(urlRequest?.httpBodyStream != nil)
- #expect(urlRequest?.httpMethod == "POST")
- }
-
- @Test("urlRequest strips hop-by-hop headers")
- func urlRequestStripsHopByHopHeaders() {
- let request = ParsedHTTPRequest.complete(
- method: "GET",
- target: "/wp/v2/posts",
- httpVersion: "HTTP/1.1",
- headers: [
- "Host": "localhost:8080",
- "Connection": "keep-alive",
- "Accept": "application/json",
- "Transfer-Encoding": "chunked",
- "Keep-Alive": "timeout=5",
- "Proxy-Connection": "keep-alive",
- ],
- body: nil
- )
-
- let baseURL = URL(string: "https://example.com")!
- let urlRequest = request.urlRequest(relativeTo: baseURL)!
-
- #expect(urlRequest.value(forHTTPHeaderField: "Host") == nil)
- #expect(urlRequest.value(forHTTPHeaderField: "Connection") == nil)
- #expect(urlRequest.value(forHTTPHeaderField: "Transfer-Encoding") == nil)
- #expect(urlRequest.value(forHTTPHeaderField: "Keep-Alive") == nil)
- #expect(urlRequest.value(forHTTPHeaderField: "Proxy-Connection") == nil)
- #expect(urlRequest.value(forHTTPHeaderField: "Accept") == "application/json")
- }
-
- // MARK: - header(_:)
-
- @Test("header returns nil for missing header")
- func headerReturnsNilForMissing() {
- let request = ParsedHTTPRequest.complete(
- method: "GET",
- target: "/",
- httpVersion: "HTTP/1.1",
- headers: ["Accept": "text/html"],
- body: nil
- )
-
- #expect(request.header("Authorization") == nil)
- }
-
- @Test("header is case-insensitive")
- func headerCaseInsensitive() {
- let request = ParsedHTTPRequest.complete(
- method: "GET",
- target: "/",
- httpVersion: "HTTP/1.1",
- headers: ["X-Custom-Header": "value123"],
- body: nil
- )
-
- #expect(request.header("x-custom-header") == "value123")
- #expect(request.header("X-CUSTOM-HEADER") == "value123")
- }
-
- // MARK: - Partial vs Complete
-
- @Test("partial request has no body")
- func partialHasNoBody() {
- let request = ParsedHTTPRequest.partial(
- method: "POST",
- target: "/wp/v2/posts",
- httpVersion: "HTTP/1.1",
- headers: ["Content-Type": "application/json"]
- )
-
- #expect(!request.isComplete)
- #expect(request.body == nil)
- #expect(request.method == "POST")
- #expect(request.target == "/wp/v2/posts")
- }
-
- @Test("complete request without body")
- func completeWithoutBody() {
- let request = ParsedHTTPRequest.complete(
- method: "GET",
- target: "/wp/v2/settings",
- httpVersion: "HTTP/1.1",
- headers: [:],
- body: nil
- )
-
- #expect(request.isComplete)
- #expect(request.body == nil)
- }
-
- // MARK: - urlRequest Edge Cases
-
- @Test("urlRequest returns nil for malformed target")
- func urlRequestReturnsNilForMalformedTarget() {
- let request = ParsedHTTPRequest.complete(
- method: "GET",
- target: "://not a valid url",
- httpVersion: "HTTP/1.1",
- headers: [:],
- body: nil
- )
-
- let baseURL = URL(string: "https://example.com")!
- #expect(request.urlRequest(relativeTo: baseURL) == nil)
- }
-
- // MARK: - Proxy-Authorization is stripped, Authorization passes through
-
- @Test("urlRequest strips Proxy-Authorization header (proxy token)")
- func urlRequestStripsProxyAuthorizationHeader() {
- let request = ParsedHTTPRequest.complete(
- method: "GET",
- target: "/wp/v2/posts",
- httpVersion: "HTTP/1.1",
- headers: [
- "Proxy-Authorization": "Bearer secret-proxy-token",
- "Authorization": "Basic dXNlcjpwYXNz",
- "Accept": "application/json",
- ],
- body: nil
- )
-
- let baseURL = URL(string: "https://example.com")!
- let urlRequest = request.urlRequest(relativeTo: baseURL)!
-
- #expect(urlRequest.value(forHTTPHeaderField: "Proxy-Authorization") == nil)
- #expect(urlRequest.value(forHTTPHeaderField: "Authorization") == "Basic dXNlcjpwYXNz")
- #expect(urlRequest.value(forHTTPHeaderField: "Accept") == "application/json")
- }
-
- @Test("urlRequest strips lowercase proxy-authorization header")
- func urlRequestStripsLowercaseProxyAuthorizationHeader() {
- let request = ParsedHTTPRequest.complete(
- method: "GET",
- target: "/wp/v2/posts",
- httpVersion: "HTTP/1.1",
- headers: [
- "proxy-authorization": "Bearer secret-proxy-token",
- "Accept": "application/json",
- ],
- body: nil
- )
-
- let baseURL = URL(string: "https://example.com")!
- let urlRequest = request.urlRequest(relativeTo: baseURL)!
-
- #expect(urlRequest.value(forHTTPHeaderField: "proxy-authorization") == nil)
- #expect(urlRequest.value(forHTTPHeaderField: "Accept") == "application/json")
- }
-
- @Test("urlRequest strips Relay-Authorization header (fetch()-compatible proxy token)")
- func urlRequestStripsRelayAuthorizationHeader() {
- let request = ParsedHTTPRequest.complete(
- method: "GET",
- target: "/wp/v2/posts",
- httpVersion: "HTTP/1.1",
- headers: [
- "Relay-Authorization": "Bearer secret-proxy-token",
- "Authorization": "Basic dXNlcjpwYXNz",
- "Accept": "application/json",
- ],
- body: nil
- )
-
- let baseURL = URL(string: "https://example.com")!
- let urlRequest = request.urlRequest(relativeTo: baseURL)!
-
- #expect(urlRequest.value(forHTTPHeaderField: "Relay-Authorization") == nil)
- #expect(urlRequest.value(forHTTPHeaderField: "Authorization") == "Basic dXNlcjpwYXNz")
- #expect(urlRequest.value(forHTTPHeaderField: "Accept") == "application/json")
- }
-
- @Test("urlRequest strips lowercase relay-authorization header")
- func urlRequestStripsLowercaseRelayAuthorizationHeader() {
- let request = ParsedHTTPRequest.complete(
- method: "GET",
- target: "/wp/v2/posts",
- httpVersion: "HTTP/1.1",
- headers: [
- "relay-authorization": "Bearer secret-proxy-token",
- "Accept": "application/json",
- ],
- body: nil
- )
-
- let baseURL = URL(string: "https://example.com")!
- let urlRequest = request.urlRequest(relativeTo: baseURL)!
-
- #expect(urlRequest.value(forHTTPHeaderField: "relay-authorization") == nil)
- #expect(urlRequest.value(forHTTPHeaderField: "Accept") == "application/json")
- }
-
- @Test("urlRequest strips both Proxy-Authorization and Relay-Authorization when both present")
- func urlRequestStripsBothProxyAndRelayAuth() {
- let request = ParsedHTTPRequest.complete(
- method: "GET",
- target: "/wp/v2/posts",
- httpVersion: "HTTP/1.1",
- headers: [
- "Proxy-Authorization": "Bearer token-a",
- "Relay-Authorization": "Bearer token-b",
- "Authorization": "Basic dXNlcjpwYXNz",
- "Accept": "application/json",
- ],
- body: nil
- )
-
- let baseURL = URL(string: "https://example.com")!
- let urlRequest = request.urlRequest(relativeTo: baseURL)!
-
- #expect(urlRequest.value(forHTTPHeaderField: "Proxy-Authorization") == nil)
- #expect(urlRequest.value(forHTTPHeaderField: "Relay-Authorization") == nil)
- #expect(urlRequest.value(forHTTPHeaderField: "Authorization") == "Basic dXNlcjpwYXNz")
- }
-
- // MARK: - Fix #2: Case-insensitive Connection header for hop-by-hop extension
-
- @Test("urlRequest strips headers listed in lowercase connection header")
- func urlRequestStripsHeadersFromLowercaseConnectionHeader() {
- let request = ParsedHTTPRequest.complete(
- method: "GET",
- target: "/wp/v2/posts",
- httpVersion: "HTTP/1.1",
- headers: [
- "connection": "X-Custom, close",
- "X-Custom": "should-be-stripped",
- "Accept": "application/json",
- ],
- body: nil
- )
-
- let baseURL = URL(string: "https://example.com")!
- let urlRequest = request.urlRequest(relativeTo: baseURL)!
-
- #expect(urlRequest.value(forHTTPHeaderField: "X-Custom") == nil)
- #expect(urlRequest.value(forHTTPHeaderField: "connection") == nil)
- #expect(urlRequest.value(forHTTPHeaderField: "Accept") == "application/json")
- }
-
- @Test("urlRequest strips headers listed in mixed-case CONNECTION header")
- func urlRequestStripsHeadersFromMixedCaseConnectionHeader() {
- let request = ParsedHTTPRequest.complete(
- method: "GET",
- target: "/wp/v2/posts",
- httpVersion: "HTTP/1.1",
- headers: [
- "CONNECTION": "X-Private",
- "X-Private": "should-be-stripped",
- "Accept": "text/html",
- ],
- body: nil
- )
-
- let baseURL = URL(string: "https://example.com")!
- let urlRequest = request.urlRequest(relativeTo: baseURL)!
-
- #expect(urlRequest.value(forHTTPHeaderField: "X-Private") == nil)
- #expect(urlRequest.value(forHTTPHeaderField: "Accept") == "text/html")
- }
-}
diff --git a/ios/Tests/GutenbergKitHTTPTests/RFC7230ConformanceTests.swift b/ios/Tests/GutenbergKitHTTPTests/RFC7230ConformanceTests.swift
deleted file mode 100644
index 4afb56201..000000000
--- a/ios/Tests/GutenbergKitHTTPTests/RFC7230ConformanceTests.swift
+++ /dev/null
@@ -1,375 +0,0 @@
-import Foundation
-import Testing
-@testable import GutenbergKitHTTP
-
-@Suite("RFC 7230 Conformance")
-struct RFC7230ConformanceTests {
-
- // MARK: - Section 3.5 (Message Parsing Robustness)
-
- @Test("RFC 7230 §3.5: single leading CRLF before request line is ignored")
- func singleLeadingCRLFIsIgnored() throws {
- // RFC 7230 §3.5: server SHOULD ignore at least one leading CRLF.
- let parser = HTTPRequestParser("\r\nGET /wp/v2/posts HTTP/1.1\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.method == "GET")
- #expect(request.target == "/wp/v2/posts")
- #expect(request.header("Host") == "localhost")
- }
-
- @Test("RFC 7230 §3.5: multiple leading CRLFs before request line are ignored")
- func multipleLeadingCRLFsAreIgnored() throws {
- // RFC 7230 §3.5: server SHOULD ignore at least one leading CRLF.
- let parser = HTTPRequestParser("\r\n\r\nGET /wp/v2/posts HTTP/1.1\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.method == "GET")
- #expect(request.target == "/wp/v2/posts")
- #expect(request.header("Host") == "localhost")
- }
-
- // MARK: - Section 3.2 (Header Fields)
-
- @Test("RFC 7230 §3.2.4: whitespace between field-name and colon is rejected")
- func whitespaceBetweenFieldNameAndColon() {
- // RFC 7230 §3.2.4: "No whitespace is allowed between the header field-name
- // and colon." This is a request smuggling vector — the parser returns the
- // specific .whitespaceBeforeColon error rather than the generic .invalidFieldName.
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nHost : localhost\r\nX-WP-Nonce : abc123\r\n\r\n")
-
- #expect(throws: HTTPRequestParseError.whitespaceBeforeColon) {
- try parser.parseRequest()
- }
- }
-
- @Test("RFC 7230 §3.2.4: obs-fold continuation line is rejected")
- func obsFoldContinuationLineRejected() {
- // RFC 7230 says server MUST reject with 400 or replace obs-fold with SP.
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nX-WP-Custom: value1\r\n continued-value\r\nHost: localhost\r\n\r\n")
-
- #expect(throws: HTTPRequestParseError.obsFoldDetected) {
- try parser.parseRequest()
- }
- }
-
- @Test("RFC 7230 §3.2.4: obs-fold with tab continuation is rejected")
- func obsFoldTabContinuationRejected() {
- // Tab-prefixed continuation lines are rejected per RFC 7230 §3.2.4.
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nAuthorization: Bearer\r\n\ttok123\r\nHost: localhost\r\n\r\n")
-
- #expect(throws: HTTPRequestParseError.obsFoldDetected) {
- try parser.parseRequest()
- }
- }
-
- @Test("RFC 7230 §3.2: field-name is a token — preserved without normalization")
- func fieldNameTokenPreservedVerbatim() throws {
- // Header field names should be treated as opaque tokens
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nX-WP-Nonce: abc\r\nX_Underscore_Header: val\r\nX-123-Numeric: num\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.headers["X-WP-Nonce"] == "abc")
- #expect(request.headers["X_Underscore_Header"] == "val")
- #expect(request.headers["X-123-Numeric"] == "num")
- }
-
- // MARK: - Section 3.3 (Message Body)
-
- @Test("RFC 7230 §3.3.3: Transfer-Encoding: chunked is rejected")
- func transferEncodingChunkedRejected() {
- let body = #"{"title":"Test"}"#
- let raw = "POST /wp/v2/posts HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)"
- let parser = HTTPRequestParser(raw)
-
- #expect(throws: HTTPRequestParseError.unsupportedTransferEncoding) {
- try parser.parseRequest()
- }
- }
-
- @Test("RFC 7230 §3.3.3: Transfer-Encoding without Content-Length is rejected")
- func transferEncodingWithoutContentLengthRejected() {
- let raw = "POST /wp/v2/posts HTTP/1.1\r\nTransfer-Encoding: chunked\r\nHost: localhost\r\n\r\n"
- let parser = HTTPRequestParser(raw)
-
- #expect(throws: HTTPRequestParseError.unsupportedTransferEncoding) {
- try parser.parseRequest()
- }
- }
-
- @Test("RFC 7230 §3.3.3: Transfer-Encoding: identity is also rejected")
- func transferEncodingIdentityRejected() {
- // Even `identity` is rejected — this server only supports Content-Length framing.
- let raw = "POST /wp/v2/posts HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: identity\r\nContent-Length: 5\r\n\r\nhello"
- let parser = HTTPRequestParser(raw)
-
- #expect(throws: HTTPRequestParseError.unsupportedTransferEncoding) {
- try parser.parseRequest()
- }
- }
-
- @Test("RFC 7230 §3.3.3: Transfer-Encoding with mixed case is rejected")
- func transferEncodingMixedCaseRejected() {
- let raw = "POST /wp/v2/posts HTTP/1.1\r\nTRANSFER-ENCODING: chunked\r\nHost: localhost\r\n\r\n"
- let parser = HTTPRequestParser(raw)
-
- #expect(throws: HTTPRequestParseError.unsupportedTransferEncoding) {
- try parser.parseRequest()
- }
- }
-
- @Test("RFC 7230 §3.3.3: duplicate identical Content-Length values — first value used by scanner")
- func duplicateIdenticalContentLength() throws {
- // RFC says recipient MUST reject or consolidate. Our parser's scanContentLength
- // finds the first match and the header dict overwrites with the last.
- let body = #"{"id":1}"#
- let raw = "POST /wp/v2/posts HTTP/1.1\r\nContent-Length: \(body.utf8.count)\r\nContent-Length: \(body.utf8.count)\r\nHost: localhost\r\n\r\n\(body)"
- let parser = HTTPRequestParser(raw)
-
- #expect(parser.state.isComplete)
- let request = try #require(try parser.parseRequest())
- let requestBody = try #require(request.body)
- #expect(try readAll(requestBody) == Data(body.utf8))
- }
-
- @Test("RFC 7230 §3.3.3: conflicting Content-Length values are rejected as invalid")
- func conflictingContentLengthRejected() {
- // RFC says conflicting Content-Length is unrecoverable error (MUST reject).
- let parser = HTTPRequestParser("POST /wp/v2/posts HTTP/1.1\r\nContent-Length: 100\r\nContent-Length: 5\r\nHost: localhost\r\n\r\nhello")
-
- #expect(throws: HTTPRequestParseError.conflictingContentLength) {
- try parser.parseRequest()
- }
- }
-
- @Test("RFC 7230 §3.3.3 step 6: request with no Content-Length and no Transfer-Encoding has zero-length body")
- func noContentLengthNoTransferEncodingMeansZeroBody() throws {
- let parser = HTTPRequestParser("DELETE /wp/v2/posts/42?force=true HTTP/1.1\r\nHost: localhost\r\nAuthorization: Bearer tok\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(parser.state.isComplete)
- #expect(request.body == nil)
- }
-
- @Test("RFC 7230 §3.3: Content-Length: 0 is explicitly zero-length body")
- func contentLengthZeroIsExplicitNoBody() throws {
- let parser = HTTPRequestParser("POST /wp/v2/posts HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(parser.state.isComplete)
- #expect(request.body == nil)
- #expect(parser.expectedBodyLength == 0)
- }
-
- // MARK: - Section 3.3.3 (Message Body Length — Incremental)
-
- @Test("RFC 7230 §3.3.3: body arriving after headers in separate chunk")
- func bodyArrivesInSeparateChunk() throws {
- let body = #"{"title":"Hello","status":"publish"}"#
- let headers = "POST /wp/v2/posts HTTP/1.1\r\nContent-Length: \(body.utf8.count)\r\nHost: localhost\r\n\r\n"
-
- let parser = HTTPRequestParser()
- parser.append(Data(headers.utf8))
- #expect(parser.state.hasHeaders)
- #expect(!parser.state.isComplete)
-
- parser.append(Data(body.utf8))
- #expect(parser.state.isComplete)
-
- let request = try #require(try parser.parseRequest())
- let requestBody = try #require(request.body)
- #expect(try readAll(requestBody) == Data(body.utf8))
- }
-
- @Test("RFC 7230 §3.4: incomplete body — fewer bytes than Content-Length")
- func incompleteBodyFewerBytesThanContentLength() throws {
- let parser = HTTPRequestParser()
- parser.append(Data("POST /wp/v2/media HTTP/1.1\r\nHost: localhost\r\nContent-Length: 500\r\n\r\n".utf8))
- parser.append(Data("partial data".utf8))
-
- #expect(parser.state.hasHeaders)
- #expect(!parser.state.isComplete)
-
- let request = try #require(try parser.parseRequest())
- #expect(!request.isComplete)
- #expect(request.method == "POST")
- #expect(request.target == "/wp/v2/media")
- }
-
- // MARK: - Section 5.3 (Request Target)
-
- @Test("RFC 7230 §5.3.1: origin-form with empty query string")
- func originFormEmptyQuery() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts? HTTP/1.1\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.target == "/wp/v2/posts?")
- }
-
- @Test("RFC 7230 §5.3.1: origin-form must start with /")
- func originFormStartsWithSlash() {
- // Relative path without leading slash — rejected per RFC 9112 §3.2
- let parser = HTTPRequestParser("GET wp/v2/posts HTTP/1.1\r\nHost: localhost\r\n\r\n")
-
- #expect(throws: HTTPRequestParseError.self) {
- try parser.parseRequest()
- }
- }
-
- @Test("RFC 7230 §5.3.2: absolute-form with HTTPS scheme")
- func absoluteFormHTTPS() throws {
- let parser = HTTPRequestParser("GET https://example.com/wp/v2/posts HTTP/1.1\r\nHost: example.com\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.target == "https://example.com/wp/v2/posts")
- }
-
- @Test("RFC 7230 §5.3.2: absolute-form with userinfo in authority")
- func absoluteFormWithUserinfo() throws {
- let parser = HTTPRequestParser("GET http://admin:pass@example.com/wp/v2/posts HTTP/1.1\r\nHost: example.com\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.target == "http://admin:pass@example.com/wp/v2/posts")
- }
-
- @Test("RFC 7230 §5.3.3: authority-form with port")
- func authorityFormWithPort() throws {
- let parser = HTTPRequestParser("CONNECT wordpress.org:8443 HTTP/1.1\r\nHost: wordpress.org:8443\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.method == "CONNECT")
- #expect(request.target == "wordpress.org:8443")
- }
-
- @Test("RFC 7230 §5.3.4: asterisk-form for server-wide OPTIONS")
- func asteriskFormServerWideOptions() throws {
- let parser = HTTPRequestParser("OPTIONS * HTTP/1.1\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.method == "OPTIONS")
- #expect(request.target == "*")
- }
-
- // MARK: - Section 5.4 (Host)
-
- @Test("RFC 7230 §5.4: request without Host header is rejected in HTTP/1.1")
- func requestWithoutHostRejected() {
- // RFC 9110 §7.2: server MUST respond 400 if Host is missing in HTTP/1.1.
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nAccept: application/json\r\n\r\n")
-
- #expect(throws: HTTPRequestParseError.missingHostHeader) {
- try parser.parseRequest()
- }
- }
-
- @Test("RFC 7230 §5.4: Host header with port")
- func hostHeaderWithPort() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nHost: localhost:8080\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Host") == "localhost:8080")
- }
-
- @Test("RFC 7230 §5.4: empty Host header is accepted")
- func emptyHostHeaderAccepted() throws {
- // RFC allows empty Host for requests to root origin server
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nHost:\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- // Optional `String?`: `== ""` asserts present-and-empty, which `isEmpty` cannot express.
- // swiftlint:disable:next empty_string
- #expect(request.header("Host") == "")
- }
-
- // MARK: - Section 3.1.1 (Request Line Edge Cases)
-
- @Test("RFC 7230 §3.1.1: request line with extra spaces between components is rejected")
- func requestLineExtraSpaces() {
- // RFC 9112 §3: request-line = method SP request-target SP HTTP-version
- // Double space produces an empty target which fails validation.
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nHost: localhost\r\n\r\n")
-
- #expect(throws: HTTPRequestParseError.invalidHTTPVersion) {
- try parser.parseRequest()
- }
- }
-
- @Test("RFC 7230 §3.1.1: request with very long method token")
- func veryLongMethodToken() throws {
- let longMethod = String(repeating: "X", count: 100)
- let parser = HTTPRequestParser("\(longMethod) /wp/v2/posts HTTP/1.1\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.method == longMethod)
- }
-
- // MARK: - Section 3 (General Message Format)
-
- @Test("RFC 7230 §3: only CRLF (no headers, no request line content) is needsMoreData")
- func onlyCRLFIsNeedsMoreData() throws {
- // Leading CRLFs are stripped per RFC 7230 §3.5, leaving no data — needsMoreData.
- let parser = HTTPRequestParser("\r\n\r\n")
-
- #expect(!parser.state.hasHeaders)
- #expect(try parser.parseRequest() == nil)
- }
-
- @Test("RFC 7230 §3: request with many headers")
- func requestWithManyHeaders() throws {
- var raw = "GET /wp/v2/posts HTTP/1.1\r\nHost: localhost\r\n"
- for i in 0..<50 {
- raw += "X-WP-Header-\(i): value-\(i)\r\n"
- }
- raw += "\r\n"
-
- let parser = HTTPRequestParser(raw)
- let request = try #require(try parser.parseRequest())
-
- #expect(parser.state.isComplete)
- #expect(request.headers.count == 51) // 50 X-WP-Header + Host
- #expect(request.header("X-WP-Header-0") == "value-0")
- #expect(request.header("X-WP-Header-49") == "value-49")
- }
-
- // MARK: - Header Count Limit
-
- @Test("rejects requests with more than 100 header field lines")
- func tooManyHeaders() {
- // 1 Host + 100 X-Headers = 101 total header lines → rejected
- var raw = "GET / HTTP/1.1\r\nHost: localhost\r\n"
- for i in 0..<100 {
- raw += "X-Header-\(i): value\r\n"
- }
- raw += "\r\n"
- let parser = HTTPRequestParser(raw)
-
- #expect(throws: HTTPRequestParseError.tooManyHeaders) {
- try parser.parseRequest()
- }
- }
-
- @Test("accepts requests with exactly 100 header field lines")
- func maxHeadersAccepted() throws {
- // 1 Host + 99 X-Headers = 100 total header lines → accepted
- var raw = "GET / HTTP/1.1\r\nHost: localhost\r\n"
- for i in 0..<99 {
- raw += "X-Header-\(i): value\r\n"
- }
- raw += "\r\n"
- let parser = HTTPRequestParser(raw)
- let request = try #require(try parser.parseRequest())
-
- #expect(request.method == "GET")
- }
-
- @Test("RFC 7230 §3: header value with all printable ASCII characters")
- func headerValueAllPrintableASCII() throws {
- // Field values can contain any VCHAR (0x21-0x7E) plus SP and HTAB.
- // Leading/trailing whitespace in the value is stripped by the parser (OWS trimming).
- let printable = "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nX-WP-Test: \(printable)\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("X-WP-Test") == printable)
- }
-}
diff --git a/ios/Tests/GutenbergKitHTTPTests/RFC7578ConformanceTests.swift b/ios/Tests/GutenbergKitHTTPTests/RFC7578ConformanceTests.swift
deleted file mode 100644
index 2bd84db0f..000000000
--- a/ios/Tests/GutenbergKitHTTPTests/RFC7578ConformanceTests.swift
+++ /dev/null
@@ -1,694 +0,0 @@
-import Foundation
-import Testing
-@testable import GutenbergKitHTTP
-
-/// Tests multipart/form-data parsing per RFC 7578.
-@Suite("RFC 7578 Conformance")
-struct RFC7578ConformanceTests {
-
- // MARK: - Content-Type / Boundary Extraction
-
- @Test("RFC 7578 §4.1: Content-Type with boundary parameter is preserved")
- func contentTypeWithBoundary() throws {
- let request = try parse(fields: [("field1", nil, nil, "value1")], boundary: "AaB03x")
-
- #expect(request.header("Content-Type") == "multipart/form-data; boundary=AaB03x")
- }
-
- @Test("RFC 7578 §4.1: Content-Type with quoted boundary extracts correctly")
- func contentTypeWithQuotedBoundary() throws {
- let boundary = "----WebKitFormBoundary7MA4YWxk"
- let body = "--\(boundary)\r\nContent-Disposition: form-data; name=\"field1\"\r\n\r\nvalue1\r\n--\(boundary)--\r\n"
- let raw = "POST /wp/v2/media HTTP/1.1\r\nHost: localhost\r\nContent-Type: multipart/form-data; boundary=\"\(boundary)\"\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)"
- let parser = HTTPRequestParser(raw)
- let request = try #require(try parser.parseRequest())
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "field1")
- }
-
- @Test("RFC 7578 §4.1: non-multipart Content-Type throws notMultipartFormData")
- func nonMultipartContentTypeThrows() throws {
- let body = #"{"title":"Test"}"#
- let raw = "POST /wp/v2/posts HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)"
- let parser = HTTPRequestParser(raw)
- let request = try #require(try parser.parseRequest())
-
- #expect(throws: MultipartParseError.notMultipartFormData) {
- try request.multipartParts()
- }
- }
-
- @Test("RFC 7578 §4.1: missing boundary parameter throws notMultipartFormData")
- func missingBoundaryThrows() throws {
- let body = "some data"
- let raw = "POST /wp/v2/posts HTTP/1.1\r\nHost: localhost\r\nContent-Type: multipart/form-data\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)"
- let parser = HTTPRequestParser(raw)
- let request = try #require(try parser.parseRequest())
-
- #expect(throws: MultipartParseError.notMultipartFormData) {
- try request.multipartParts()
- }
- }
-
- // MARK: - Single Text Field
-
- @Test("RFC 7578 §4.2: single text field parsed correctly")
- func singleTextField() throws {
- let request = try parse(fields: [("title", nil, nil, "My Blog Post")], boundary: "AaB03x")
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "title")
- #expect(parts[0].filename == nil)
- #expect(parts[0].contentType == "text/plain")
- #expect(try readAll(parts[0].body) == Data("My Blog Post".utf8))
- }
-
- @Test("RFC 7578 §4.2: field with empty value")
- func fieldWithEmptyValue() throws {
- let request = try parse(fields: [("excerpt", nil, nil, "")], boundary: "AaB03x")
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "excerpt")
- #expect(try readAll(parts[0].body) == Data())
- }
-
- // MARK: - Multiple Fields
-
- @Test("RFC 7578 §4.2: multiple text fields in order")
- func multipleTextFields() throws {
- let request = try parse(fields: [
- ("title", nil, nil, "My Post"),
- ("status", nil, nil, "publish"),
- ("content", nil, nil, "Hello world
"),
- ], boundary: "AaB03x")
- let parts = try request.multipartParts()
-
- #expect(parts.count == 3)
- #expect(parts[0].name == "title")
- #expect(try readAll(parts[0].body) == Data("My Post".utf8))
- #expect(parts[1].name == "status")
- #expect(try readAll(parts[1].body) == Data("publish".utf8))
- #expect(parts[2].name == "content")
- #expect(try readAll(parts[2].body) == Data("Hello world
".utf8))
- }
-
- // MARK: - File Upload
-
- @Test("RFC 7578 §4.2: file upload with filename and content-type")
- func fileUploadWithFilename() throws {
- let fileContent = "Hello, this is a test file."
- let request = try parse(fields: [
- ("file", "test.txt", "text/plain", fileContent),
- ], boundary: "AaB03x")
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "file")
- #expect(parts[0].filename == "test.txt")
- #expect(parts[0].contentType == "text/plain")
- #expect(try readAll(parts[0].body) == Data(fileContent.utf8))
- }
-
- @Test("RFC 7578 §4.2: file upload with application/octet-stream")
- func fileUploadOctetStream() throws {
- let request = try parse(fields: [
- ("upload", "data.bin", "application/octet-stream", "binary-content"),
- ], boundary: "boundary42")
- let parts = try request.multipartParts()
-
- #expect(parts[0].filename == "data.bin")
- #expect(parts[0].contentType == "application/octet-stream")
- }
-
- @Test("RFC 7578 §4.4: part without Content-Type defaults to text/plain")
- func partWithoutContentTypeDefaultsToTextPlain() throws {
- let request = try parse(fields: [("field", nil, nil, "value")], boundary: "AaB03x")
- let parts = try request.multipartParts()
-
- #expect(parts[0].contentType == "text/plain")
- }
-
- // MARK: - Mixed Fields and Files
-
- @Test("RFC 7578 §4.2: form with text fields and file upload")
- func formWithTextAndFile() throws {
- let request = try parse(fields: [
- ("title", nil, nil, "My Image Post"),
- ("file", "image.jpg", "image/jpeg", "JFIF-binary-data"),
- ], boundary: "AaB03x")
- let parts = try request.multipartParts()
-
- #expect(parts.count == 2)
- #expect(parts[0].name == "title")
- #expect(parts[0].filename == nil)
- #expect(parts[1].name == "file")
- #expect(parts[1].filename == "image.jpg")
- #expect(parts[1].contentType == "image/jpeg")
- }
-
- // MARK: - Section 5.1 (Multiple Files for One Field)
-
- @Test("RFC 7578 §5.1: multiple files with same field name in separate parts")
- func multipleFilesWithSameFieldName() throws {
- let request = try parse(fields: [
- ("documents", "file1.txt", "text/plain", "First file"),
- ("documents", "file2.txt", "text/plain", "Second file"),
- ], boundary: "AaB03x")
- let parts = try request.multipartParts()
-
- #expect(parts.count == 2)
- #expect(parts[0].name == "documents")
- #expect(parts[0].filename == "file1.txt")
- #expect(try readAll(parts[0].body) == Data("First file".utf8))
- #expect(parts[1].name == "documents")
- #expect(parts[1].filename == "file2.txt")
- #expect(try readAll(parts[1].body) == Data("Second file".utf8))
- }
-
- // MARK: - Section 5.1.2 (Filenames with Special Characters)
-
- @Test("RFC 7578 §5.1.2: filename with spaces")
- func filenameWithSpaces() throws {
- let request = try parse(fields: [
- ("file", "my document.pdf", "application/pdf", "pdf-data"),
- ], boundary: "AaB03x")
- let parts = try request.multipartParts()
-
- #expect(parts[0].filename == "my document.pdf")
- }
-
- @Test("RFC 7578 §5.1.2: filename with percent-encoded UTF-8")
- func filenameWithPercentEncodedUTF8() throws {
- let request = try parse(fields: [
- ("file", "caf%C3%A9.txt", "text/plain", "data"),
- ], boundary: "AaB03x")
- let parts = try request.multipartParts()
-
- // The parser preserves the raw filename — percent-decoding is the caller's concern
- #expect(parts[0].filename == "caf%C3%A9.txt")
- }
-
- @Test("RFC 7578 §5.1.2: filename with direct UTF-8 encoding")
- func filenameWithDirectUTF8() throws {
- let request = try parse(fields: [
- ("file", "café.txt", "text/plain", "data"),
- ], boundary: "AaB03x")
- let parts = try request.multipartParts()
-
- #expect(parts[0].filename == "café.txt")
- }
-
- // MARK: - Section 5.1.3 (_charset_ Field)
-
- @Test("RFC 7578 §5.1.3: _charset_ field is parsed as a normal field")
- func charsetFieldParsed() throws {
- let request = try parse(fields: [
- ("_charset_", nil, nil, "UTF-8"),
- ("title", nil, nil, "My Post"),
- ], boundary: "AaB03x")
- let parts = try request.multipartParts()
-
- #expect(parts.count == 2)
- #expect(parts[0].name == "_charset_")
- #expect(try readAll(parts[0].body) == Data("UTF-8".utf8))
- }
-
- // MARK: - Part Content-Type Variations
-
- @Test("RFC 7578 §4.4: part with charset parameter in Content-Type")
- func partWithCharsetParameter() throws {
- let boundary = "AaB03x"
- let body = "--\(boundary)\r\nContent-Disposition: form-data; name=\"bio\"\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nHello world\r\n--\(boundary)--\r\n"
- let request = try parseRaw(body: body, boundary: boundary)
- let parts = try request.multipartParts()
-
- #expect(parts[0].contentType == "text/plain; charset=UTF-8")
- }
-
- // MARK: - Boundary Edge Cases
-
- @Test("RFC 7578: boundary with hyphens (common browser format)")
- func boundaryWithHyphens() throws {
- let request = try parse(
- fields: [("title", nil, nil, "test")],
- boundary: "----WebKitFormBoundary7MA4YWxkTrZu0gW"
- )
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "title")
- }
-
- @Test("RFC 7578: long boundary (70 characters)")
- func longBoundary() throws {
- let request = try parse(
- fields: [("f", nil, nil, "v")],
- boundary: String(repeating: "x", count: 70)
- )
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- }
-
- @Test("RFC 7578: body content resembling boundary is not split")
- func bodyContentResemblingBoundary() throws {
- let boundary = "AaB03x"
- let body = "--\(boundary)\r\nContent-Disposition: form-data; name=\"data\"\r\n\r\n--AaB03 not a boundary\r\n--\(boundary)--\r\n"
- let request = try parseRaw(body: body, boundary: boundary)
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(try readAll(parts[0].body) == Data("--AaB03 not a boundary".utf8))
- }
-
- // MARK: - Error Cases
-
- @Test("RFC 7578: part missing Content-Disposition throws error")
- func missingContentDisposition() throws {
- let boundary = "AaB03x"
- let body = "--\(boundary)\r\nContent-Type: text/plain\r\n\r\nvalue\r\n--\(boundary)--\r\n"
- let request = try parseRaw(body: body, boundary: boundary)
-
- #expect(throws: MultipartParseError.missingContentDisposition) {
- try request.multipartParts()
- }
- }
-
- @Test("RFC 7578: part missing name parameter throws error")
- func missingNameParameter() throws {
- let boundary = "AaB03x"
- let body = "--\(boundary)\r\nContent-Disposition: form-data\r\n\r\nvalue\r\n--\(boundary)--\r\n"
- let request = try parseRaw(body: body, boundary: boundary)
-
- #expect(throws: MultipartParseError.missingNameParameter) {
- try request.multipartParts()
- }
- }
-
- @Test("RFC 7578: malformed body throws error")
- func malformedBody() throws {
- let boundary = "AaB03x"
- let body = "this is not multipart at all"
- let request = try parseRaw(body: body, boundary: boundary)
-
- #expect(throws: MultipartParseError.malformedBody) {
- try request.multipartParts()
- }
- }
-
- @Test("RFC 7578: incomplete request throws missingBody")
- func incompleteRequestThrowsMissingBody() throws {
- let parser = HTTPRequestParser("POST /wp/v2/media HTTP/1.1\r\nHost: localhost\r\nContent-Type: multipart/form-data; boundary=AaB03x\r\nContent-Length: 1000\r\n\r\npartial")
- let request = try #require(try parser.parseRequest())
-
- // Request is partial — body not fully received
- #expect(!request.isComplete)
- #expect(throws: MultipartParseError.missingBody) {
- try request.multipartParts()
- }
- }
-
- // MARK: - Incremental Arrival
-
- @Test("RFC 7578: multipart body arriving incrementally parses correctly")
- func multipartBodyArrivingIncrementally() throws {
- let boundary = "AaB03x"
- let body = "--\(boundary)\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nMy Post\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"content\"\r\n\r\nHello
\r\n--\(boundary)--\r\n"
- let headers = "POST /wp/v2/posts HTTP/1.1\r\nHost: localhost\r\nContent-Type: multipart/form-data; boundary=\(boundary)\r\nContent-Length: \(body.utf8.count)\r\n\r\n"
-
- let parser = HTTPRequestParser()
- parser.append(Data(headers.utf8))
-
- let bodyData = Data(body.utf8)
- let midpoint = bodyData.count / 2
- parser.append(bodyData[0..Hello
".utf8))
- }
-
- // MARK: - Large Bodies
-
- @Test("RFC 7578: large part body is captured completely")
- func largePartBody() throws {
- let largeContent = String(repeating: "x", count: 10000)
- let request = try parse(fields: [("content", nil, nil, largeContent)], boundary: "AaB03x")
- let parts = try request.multipartParts()
-
- #expect(try readAll(parts[0].body) == Data(largeContent.utf8))
- }
-
- // MARK: - Body Content Edge Cases
-
- @Test("RFC 7578: binary data (non-UTF-8 bytes) in part body")
- func binaryDataInPartBody() throws {
- // PNG file signature bytes (includes 0x0D 0x0A which is CRLF)
- let binaryBytes: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
- let binaryData = Data(binaryBytes)
- let boundary = "AaB03x"
-
- var bodyData = Data()
- bodyData.append(Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"image.png\"\r\nContent-Type: image/png\r\n\r\n".utf8))
- bodyData.append(binaryData)
- bodyData.append(Data("\r\n--\(boundary)--\r\n".utf8))
-
- let raw = "POST /wp/v2/media HTTP/1.1\r\nHost: localhost\r\nContent-Type: multipart/form-data; boundary=\(boundary)\r\nContent-Length: \(bodyData.count)\r\n\r\n"
-
- let parser = HTTPRequestParser()
- parser.append(Data(raw.utf8))
- parser.append(bodyData)
-
- let request = try #require(try parser.parseRequest())
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "file")
- #expect(parts[0].filename == "image.png")
- #expect(try readAll(parts[0].body) == binaryData)
- }
-
- @Test("RFC 7578: part body containing CRLF sequences")
- func partBodyContainingCRLF() throws {
- let boundary = "AaB03x"
- let body = "--\(boundary)\r\nContent-Disposition: form-data; name=\"content\"\r\n\r\nline1\r\nline2\r\nline3\r\n--\(boundary)--\r\n"
- let request = try parseRaw(body: body, boundary: boundary)
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(try readAll(parts[0].body) == Data("line1\r\nline2\r\nline3".utf8))
- }
-
- @Test("RFC 7578: part body containing text resembling a different closing delimiter")
- func partBodyResemblingOtherClosingDelimiter() throws {
- let boundary = "AaB03x"
- let body = "--\(boundary)\r\nContent-Disposition: form-data; name=\"data\"\r\n\r\nsome --other-- text\r\n--\(boundary)--\r\n"
- let request = try parseRaw(body: body, boundary: boundary)
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(try readAll(parts[0].body) == Data("some --other-- text".utf8))
- }
-
- @Test("RFC 2046: empty multipart body with only close delimiter throws malformedBody")
- func emptyMultipartBodyThrows() throws {
- let boundary = "AaB03x"
- // RFC 2046 requires at least one body part
- let body = "--\(boundary)--\r\n"
- let request = try parseRaw(body: body, boundary: boundary)
-
- #expect(throws: MultipartParseError.malformedBody) {
- try request.multipartParts()
- }
- }
-
- // MARK: - Header Edge Cases
-
- @Test("RFC 7578: Content-Disposition with extra whitespace around parameters")
- func contentDispositionExtraWhitespace() throws {
- let boundary = "AaB03x"
- let body = "--\(boundary)\r\nContent-Disposition: form-data; name=\"field1\"\r\n\r\nvalue1\r\n--\(boundary)--\r\n"
- let request = try parseRaw(body: body, boundary: boundary)
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "field1")
- }
-
- @Test("RFC 7578: Content-Disposition name with escaped quotes")
- func contentDispositionEscapedQuotes() throws {
- let boundary = "AaB03x"
- let body = "--\(boundary)\r\nContent-Disposition: form-data; name=\"field\\\"name\"\r\n\r\nvalue\r\n--\(boundary)--\r\n"
- let request = try parseRaw(body: body, boundary: boundary)
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "field\"name")
- }
-
- @Test("RFC 7578: additional part headers beyond Content-Disposition and Content-Type are ignored")
- func additionalPartHeadersIgnored() throws {
- let boundary = "AaB03x"
- let body = "--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: binary\r\nX-Custom-Header: custom-value\r\n\r\nfile content\r\n--\(boundary)--\r\n"
- let request = try parseRaw(body: body, boundary: boundary)
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "file")
- #expect(parts[0].contentType == "text/plain")
- #expect(try readAll(parts[0].body) == Data("file content".utf8))
- }
-
- @Test("RFC 7578: case-insensitive Content-Disposition header name")
- func caseInsensitiveContentDisposition() throws {
- let boundary = "AaB03x"
- let body = "--\(boundary)\r\ncontent-disposition: form-data; name=\"field1\"\r\n\r\nvalue1\r\n--\(boundary)--\r\n"
- let request = try parseRaw(body: body, boundary: boundary)
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "field1")
- }
-
- @Test("RFC 7578: case-insensitive form-data token in Content-Disposition")
- func caseInsensitiveFormData() throws {
- let boundary = "AaB03x"
- let body = "--\(boundary)\r\nContent-Disposition: FORM-DATA; name=\"field1\"\r\n\r\nvalue1\r\n--\(boundary)--\r\n"
- let request = try parseRaw(body: body, boundary: boundary)
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "field1")
- }
-
- // MARK: - Boundary Edge Cases
-
- @Test("RFC 7578 §4.2: name= inside another parameter's quoted value is not matched")
- func nameInsideQuotedValueNotMatched() throws {
- let boundary = "AaB03x"
- let body = "--\(boundary)\r\nContent-Disposition: form-data; dummy=\"name=evil\"; name=\"real\"\r\n\r\nvalue\r\n--\(boundary)--\r\n"
- let raw = "POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Type: multipart/form-data; boundary=\(boundary)\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)"
- let parser = HTTPRequestParser(raw)
- let request = try #require(try parser.parseRequest())
- let parts = try request.multipartParts()
-
- #expect(parts[0].name == "real")
- }
-
- @Test("RFC 2045 §5.1: quoted boundary with backslash-escaped single-quote")
- func quotedBoundaryWithEscapedSingleQuote() throws {
- let unescapedBoundary = "abc'def"
- let body = "--\(unescapedBoundary)\r\nContent-Disposition: form-data; name=\"field\"\r\n\r\nvalue\r\n--\(unescapedBoundary)--\r\n"
- let raw = "POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Type: multipart/form-data; boundary=\"abc\\'def\"\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)"
- let parser = HTTPRequestParser(raw)
- let request = try #require(try parser.parseRequest())
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "field")
- }
-
- @Test("RFC 2045 §5.1: boundary= inside another parameter's quoted value is not matched")
- func boundaryInsideQuotedParameterNotMatched() throws {
- let realBoundary = "RealBoundary123"
- let body = "--\(realBoundary)\r\nContent-Disposition: form-data; name=\"field\"\r\n\r\nvalue\r\n--\(realBoundary)--\r\n"
- let raw = "POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Type: multipart/form-data; charset=\"boundary=fake\"; boundary=\(realBoundary)\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)"
- let parser = HTTPRequestParser(raw)
- let request = try #require(try parser.parseRequest())
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "field")
- }
-
- @Test("RFC 7578: boundary with special characters (plus, equals, slash)")
- func boundaryWithSpecialCharacters() throws {
- let boundary = "abc+def/ghi=123"
- let request = try parse(fields: [("field", nil, nil, "value")], boundary: boundary)
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "field")
- }
-
- @Test("RFC 2046: preamble before first boundary is ignored")
- func preambleBeforeFirstBoundaryIgnored() throws {
- let boundary = "AaB03x"
- let body = "This is the preamble. It should be ignored.\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"field1\"\r\n\r\nvalue1\r\n--\(boundary)--\r\n"
- let request = try parseRaw(body: body, boundary: boundary)
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "field1")
- #expect(try readAll(parts[0].body) == Data("value1".utf8))
- }
-
- @Test("RFC 2046: epilogue after closing boundary is ignored")
- func epilogueAfterClosingBoundaryIgnored() throws {
- let boundary = "AaB03x"
- let body = "--\(boundary)\r\nContent-Disposition: form-data; name=\"field1\"\r\n\r\nvalue1\r\n--\(boundary)--\r\nThis is the epilogue. It should be ignored.\r\n"
- let request = try parseRaw(body: body, boundary: boundary)
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "field1")
- #expect(try readAll(parts[0].body) == Data("value1".utf8))
- }
-
- @Test("RFC 2046 §5.1.1: transport padding between parts does not corrupt body data")
- func transportPaddingBetweenPartsDoesNotCorruptBody() throws {
- let boundary = "AaB03x"
- // Transport padding (spaces) after the boundary delimiter, between two parts.
- // RFC 2046 §5.1.1: delimiter = CRLF "--" boundary *( SP / HTAB ) CRLF
- // The parser should strip the padding so it doesn't end up in part bodies.
- let body = "--\(boundary) \r\nContent-Disposition: form-data; name=\"field1\"\r\n\r\nvalue1\r\n--\(boundary) \r\nContent-Disposition: form-data; name=\"field2\"\r\n\r\nvalue2\r\n--\(boundary)--\r\n"
- let request = try parseRaw(body: body, boundary: boundary)
- let parts = try request.multipartParts()
-
- #expect(parts.count == 2)
- #expect(parts[0].name == "field1")
- // The body should be exactly "value1", not "value1" with trailing padding artifacts.
- #expect(try readAll(parts[0].body) == Data("value1".utf8))
- #expect(parts[1].name == "field2")
- #expect(try readAll(parts[1].body) == Data("value2".utf8))
- }
-
- @Test("RFC 2046 §5.1.1: transport padding (tabs) after boundary does not corrupt headers")
- func transportPaddingTabsDoNotCorruptHeaders() throws {
- let boundary = "AaB03x"
- // Tabs after the boundary delimiter before the CRLF
- let body = "--\(boundary)\t\t\r\nContent-Disposition: form-data; name=\"field1\"\r\n\r\nvalue1\r\n--\(boundary)--\r\n"
- let request = try parseRaw(body: body, boundary: boundary)
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- // The name should be "field1" — padding should not cause header parsing to break.
- #expect(parts[0].name == "field1")
- #expect(try readAll(parts[0].body) == Data("value1".utf8))
- }
-
- // MARK: - WordPress / Real-World Scenarios
-
- @Test("WordPress: media upload with file and metadata fields")
- func wordPressMediaUpload() throws {
- let request = try parse(fields: [
- ("title", nil, nil, "My Featured Image"),
- ("alt_text", nil, nil, "A beautiful sunset over the mountains"),
- ("caption", nil, nil, "Photo taken at Yosemite National Park"),
- ("description", nil, nil, "Full resolution sunset photo"),
- ("file", "sunset.jpg", "image/jpeg", "JFIF-binary-data-here"),
- ], boundary: "----WebKitFormBoundary7MA4YWxk")
- let parts = try request.multipartParts()
-
- #expect(parts.count == 5)
- #expect(parts[0].name == "title")
- #expect(try readAll(parts[0].body) == Data("My Featured Image".utf8))
- #expect(parts[1].name == "alt_text")
- #expect(try readAll(parts[1].body) == Data("A beautiful sunset over the mountains".utf8))
- #expect(parts[2].name == "caption")
- #expect(parts[3].name == "description")
- #expect(parts[4].name == "file")
- #expect(parts[4].filename == "sunset.jpg")
- #expect(parts[4].contentType == "image/jpeg")
- }
-
- @Test("WordPress: multiple image uploads in a single request")
- func multipleImageUploads() throws {
- let request = try parse(fields: [
- ("files", "photo1.jpg", "image/jpeg", "jpeg-data-1"),
- ("files", "photo2.png", "image/png", "png-data-2"),
- ("files", "photo3.gif", "image/gif", "gif-data-3"),
- ], boundary: "----WebKitFormBoundary9876")
- let parts = try request.multipartParts()
-
- #expect(parts.count == 3)
- #expect(parts[0].filename == "photo1.jpg")
- #expect(parts[0].contentType == "image/jpeg")
- #expect(parts[1].filename == "photo2.png")
- #expect(parts[1].contentType == "image/png")
- #expect(parts[2].filename == "photo3.gif")
- #expect(parts[2].contentType == "image/gif")
- #expect(try readAll(parts[0].body) == Data("jpeg-data-1".utf8))
- #expect(try readAll(parts[1].body) == Data("png-data-2".utf8))
- #expect(try readAll(parts[2].body) == Data("gif-data-3".utf8))
- }
-
- @Test("WordPress: file upload with zero-byte body (empty file)")
- func emptyFileUpload() throws {
- let request = try parse(fields: [
- ("file", "empty.txt", "text/plain", ""),
- ], boundary: "AaB03x")
- let parts = try request.multipartParts()
-
- #expect(parts.count == 1)
- #expect(parts[0].name == "file")
- #expect(parts[0].filename == "empty.txt")
- #expect(try readAll(parts[0].body) == Data())
- }
-
- // MARK: - Part Count Limit
-
- @Test("rejects multipart body with more than 100 parts")
- func tooManyParts() throws {
- var fields: [(name: String, filename: String?, contentType: String?, value: String)] = []
- for i in 0..<101 {
- fields.append(("field\(i)", nil, nil, "value\(i)"))
- }
- let request = try parse(fields: fields, boundary: "AaB03x")
-
- #expect(throws: MultipartParseError.tooManyParts) {
- try request.multipartParts()
- }
- }
-
- @Test("accepts multipart body with exactly 100 parts")
- func maxPartsAccepted() throws {
- var fields: [(name: String, filename: String?, contentType: String?, value: String)] = []
- for i in 0..<100 {
- fields.append(("field\(i)", nil, nil, "value\(i)"))
- }
- let request = try parse(fields: fields, boundary: "AaB03x")
- let parts = try request.multipartParts()
-
- #expect(parts.count == 100)
- }
-
- // MARK: - Helpers
-
- /// Builds a multipart/form-data request from field descriptors and parses it.
- private func parse(
- fields: [(name: String, filename: String?, contentType: String?, value: String)],
- boundary: String
- ) throws -> ParsedHTTPRequest {
- var bodyParts: [String] = []
- for field in fields {
- var partHeaders = "Content-Disposition: form-data; name=\"\(field.name)\""
- if let filename = field.filename {
- partHeaders += "; filename=\"\(filename)\""
- }
- if let ct = field.contentType {
- partHeaders += "\r\nContent-Type: \(ct)"
- }
- bodyParts.append("--\(boundary)\r\n\(partHeaders)\r\n\r\n\(field.value)")
- }
- let body = bodyParts.joined(separator: "\r\n") + "\r\n--\(boundary)--\r\n"
-
- return try parseRaw(body: body, boundary: boundary)
- }
-
- private func parseRaw(body: String, boundary: String) throws -> ParsedHTTPRequest {
- let raw = "POST /wp/v2/posts HTTP/1.1\r\nHost: localhost\r\nContent-Type: multipart/form-data; boundary=\(boundary)\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)"
- let parser = HTTPRequestParser(raw)
- return try #require(try parser.parseRequest())
- }
-}
diff --git a/ios/Tests/GutenbergKitHTTPTests/RFC8941ConformanceTests.swift b/ios/Tests/GutenbergKitHTTPTests/RFC8941ConformanceTests.swift
deleted file mode 100644
index 1df36f6db..000000000
--- a/ios/Tests/GutenbergKitHTTPTests/RFC8941ConformanceTests.swift
+++ /dev/null
@@ -1,423 +0,0 @@
-import Foundation
-import Testing
-@testable import GutenbergKitHTTP
-
-/// Tests that the HTTP parser correctly preserves RFC 8941 Structured Field Values.
-///
-/// Our parser treats field values as opaque strings — it does not parse structured
-/// fields internally. These tests verify that all RFC 8941 syntax constructs pass
-/// through the parser without being mangled, truncated, or misinterpreted.
-@Suite("RFC 8941 Conformance")
-struct RFC8941ConformanceTests {
-
- // MARK: - Section 3.1 (Lists)
-
- @Test("RFC 8941 §3.1: simple list of tokens")
- func simpleListOfTokens() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-List: sugar, tea, rum\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-List") == "sugar, tea, rum")
- }
-
- @Test("RFC 8941 §3.1: list with parameters on members")
- func listWithParameters() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-List: abc;a=1;b=2, cde_456\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-List") == "abc;a=1;b=2, cde_456")
- }
-
- @Test("RFC 8941 §3.1: empty list is represented by absent header")
- func emptyListAbsentHeader() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-List") == nil)
- }
-
- @Test("RFC 8941 §3.1: list with inner lists")
- func listWithInnerLists() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-List: (\"foo\" \"bar\"), (\"baz\"), (\"bat\" \"one\"), ()\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-List") == "(\"foo\" \"bar\"), (\"baz\"), (\"bat\" \"one\"), ()")
- }
-
- @Test("RFC 8941 §3.1: list with parameterised inner lists")
- func listWithParameterisedInnerLists() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-List: (\"foo\";a=1;b=2);lvl=5, (\"bar\" \"baz\");lvl=1\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-List") == "(\"foo\";a=1;b=2);lvl=5, (\"bar\" \"baz\");lvl=1")
- }
-
- @Test("RFC 8941 §3.1: list spread across multiple header lines is combined")
- func listSpreadAcrossMultipleHeaderLines() throws {
- // RFC 8941 notes that list-based fields can be split across multiple lines
- // and combined per RFC 9110 §5.3
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-List: sugar, tea\r\nExample-List: rum\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-List") == "sugar, tea, rum")
- }
-
- // MARK: - Section 3.2 (Dictionaries)
-
- @Test("RFC 8941 §3.2: simple dictionary")
- func simpleDictionary() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Dict: en=\"Applepie\", da=:w4teleAA=:\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Dict") == "en=\"Applepie\", da=:w4teleAA=:")
- }
-
- @Test("RFC 8941 §3.2: dictionary with boolean true values (value omitted)")
- func dictionaryWithBooleanTrueOmitted() throws {
- // When a dictionary value is boolean true, the =?1 is omitted
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Dict: a=?0, b, c; foo=bar\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Dict") == "a=?0, b, c; foo=bar")
- }
-
- @Test("RFC 8941 §3.2: dictionary with inner list values")
- func dictionaryWithInnerListValues() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Dict: rating=1.5, feelings=(joy sadness)\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Dict") == "rating=1.5, feelings=(joy sadness)")
- }
-
- @Test("RFC 8941 §3.2: dictionary with parameters on members")
- func dictionaryWithParametersOnMembers() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Dict: abc=123;a=1;b=2, def=456, ghi=789;q=9;r=\"+w\"\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Dict") == "abc=123;a=1;b=2, def=456, ghi=789;q=9;r=\"+w\"")
- }
-
- @Test("RFC 8941 §3.2: dictionary spread across multiple header lines")
- func dictionarySpreadAcrossMultipleHeaderLines() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Dict: a=1, b=2\r\nExample-Dict: c=3\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Dict") == "a=1, b=2, c=3")
- }
-
- // MARK: - Section 3.3 (Items)
-
- @Test("RFC 8941 §3.3: item with parameters")
- func itemWithParameters() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Item: 5;foo=bar\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Item") == "5;foo=bar")
- }
-
- // MARK: - Section 3.3.1 (Integers)
-
- @Test("RFC 8941 §3.3.1: positive integer")
- func positiveInteger() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Integer: 42\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Integer") == "42")
- }
-
- @Test("RFC 8941 §3.3.1: negative integer")
- func negativeInteger() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Integer: -42\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Integer") == "-42")
- }
-
- @Test("RFC 8941 §3.3.1: zero")
- func zeroInteger() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Integer: 0\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Integer") == "0")
- }
-
- @Test("RFC 8941 §3.3.1: maximum 15-digit integer")
- func maximumInteger() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Integer: 999999999999999\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Integer") == "999999999999999")
- }
-
- @Test("RFC 8941 §3.3.1: minimum 15-digit negative integer")
- func minimumInteger() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Integer: -999999999999999\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Integer") == "-999999999999999")
- }
-
- // MARK: - Section 3.3.2 (Decimals)
-
- @Test("RFC 8941 §3.3.2: simple decimal")
- func simpleDecimal() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Decimal: 4.5\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Decimal") == "4.5")
- }
-
- @Test("RFC 8941 §3.3.2: negative decimal")
- func negativeDecimal() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Decimal: -3.14\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Decimal") == "-3.14")
- }
-
- @Test("RFC 8941 §3.3.2: decimal with three fractional digits (maximum precision)")
- func decimalMaxPrecision() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Decimal: 123456789012.123\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Decimal") == "123456789012.123")
- }
-
- // MARK: - Section 3.3.3 (Strings)
-
- @Test("RFC 8941 §3.3.3: simple string")
- func simpleString() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-String: \"hello world\"\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-String") == "\"hello world\"")
- }
-
- @Test("RFC 8941 §3.3.3: string with escaped backslash")
- func stringWithEscapedBackslash() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-String: \"path\\\\to\\\\file\"\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-String") == "\"path\\\\to\\\\file\"")
- }
-
- @Test("RFC 8941 §3.3.3: string with escaped double quote")
- func stringWithEscapedQuote() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-String: \"she said \\\"hi\\\"\"\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-String") == "\"she said \\\"hi\\\"\"")
- }
-
- @Test("RFC 8941 §3.3.3: empty string")
- func emptyString() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-String: \"\"\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-String") == "\"\"")
- }
-
- @Test("RFC 8941 §3.3.3: string with special printable ASCII characters")
- func stringWithSpecialASCII() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-String: \"!#$%&'()*+,-./:;<=>?@[]^_`{|}~\"\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-String") == "\"!#$%&'()*+,-./:;<=>?@[]^_`{|}~\"")
- }
-
- // MARK: - Section 3.3.4 (Tokens)
-
- @Test("RFC 8941 §3.3.4: simple token")
- func simpleToken() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Token: foo123\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Token") == "foo123")
- }
-
- @Test("RFC 8941 §3.3.4: token starting with asterisk")
- func tokenStartingWithAsterisk() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Token: *foo\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Token") == "*foo")
- }
-
- @Test("RFC 8941 §3.3.4: token with colon and slash")
- func tokenWithColonAndSlash() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Token: foo/bar:baz\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Token") == "foo/bar:baz")
- }
-
- @Test("RFC 8941 §3.3.4: token with tchar characters")
- func tokenWithTcharCharacters() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Token: application/x-www-form-urlencoded\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Token") == "application/x-www-form-urlencoded")
- }
-
- // MARK: - Section 3.3.5 (Byte Sequences)
-
- @Test("RFC 8941 §3.3.5: base64-encoded byte sequence")
- func base64ByteSequence() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-ByteSeq: :cHJldGVuZCB0aGlzIGlzIGJpbmFyeS8=:\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-ByteSeq") == ":cHJldGVuZCB0aGlzIGlzIGJpbmFyeS8=:")
- }
-
- @Test("RFC 8941 §3.3.5: empty byte sequence")
- func emptyByteSequence() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-ByteSeq: ::\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-ByteSeq") == "::")
- }
-
- @Test("RFC 8941 §3.3.5: byte sequence colon delimiters are not confused with header field syntax")
- func byteSequenceColonsNotConfusedWithFieldSyntax() throws {
- // The colons in :base64: must not be misinterpreted by the header parser
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-ByteSeq: :AQID:\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-ByteSeq") == ":AQID:")
- }
-
- // MARK: - Section 3.3.6 (Booleans)
-
- @Test("RFC 8941 §3.3.6: boolean true")
- func booleanTrue() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Boolean: ?1\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Boolean") == "?1")
- }
-
- @Test("RFC 8941 §3.3.6: boolean false")
- func booleanFalse() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Boolean: ?0\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Boolean") == "?0")
- }
-
- // MARK: - Section 3.1.2 (Parameters)
-
- @Test("RFC 8941 §3.1.2: parameters with various value types")
- func parametersWithVariousTypes() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Param: token;str=\"val\";int=42;dec=1.5;bool=?1;bin=:AQID:\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Param") == "token;str=\"val\";int=42;dec=1.5;bool=?1;bin=:AQID:")
- }
-
- @Test("RFC 8941 §3.1.2: boolean true parameter with value omitted")
- func booleanTrueParameterOmitted() throws {
- // Serialised form of a boolean true parameter omits =?1
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Item: 1; a; b=?0\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Item") == "1; a; b=?0")
- }
-
- @Test("RFC 8941 §3.1.2: multiple parameters with same key — last wins")
- func duplicateParameterKeysLastWins() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Item: token;a=1;a=2\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- // Parser preserves the raw value — structured field parsing is application-level
- #expect(request.header("Example-Item") == "token;a=1;a=2")
- }
-
- @Test("RFC 8941 §3.1.2: parameter keys use lowercase and special characters")
- func parameterKeysLowercaseAndSpecial() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Item: token;*key=1;a-b=2;c.d=3;e_f=4\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Item") == "token;*key=1;a-b=2;c.d=3;e_f=4")
- }
-
- // MARK: - Section 3.1.1 (Inner Lists)
-
- @Test("RFC 8941 §3.1.1: inner list with parameters on items and list")
- func innerListWithParametersOnItemsAndList() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-List: (\"foo\";a=1 \"bar\";b=2);lvl=5\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-List") == "(\"foo\";a=1 \"bar\";b=2);lvl=5")
- }
-
- @Test("RFC 8941 §3.1.1: empty inner list")
- func emptyInnerList() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-List: ()\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-List") == "()")
- }
-
- @Test("RFC 8941 §3.1.1: inner list with mixed item types")
- func innerListWithMixedTypes() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-List: (token 42 3.14 \"string\" ?1 :AQID:)\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-List") == "(token 42 3.14 \"string\" ?1 :AQID:)")
- }
-
- // MARK: - Complex / Real-World Structured Headers
-
- @Test("RFC 8941: Priority header (RFC 9218) uses structured dictionary")
- func priorityHeader() throws {
- // Priority is a real-world structured dictionary header
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nPriority: u=3, i\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Priority") == "u=3, i")
- }
-
- @Test("RFC 8941: complex nested structure with all types")
- func complexNestedStructure() throws {
- let value = "a=(1 2.0 \"three\");q=0.9, b=:AQID:;flag, c=token;*key=?0"
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Complex: \(value)\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Complex") == value)
- }
-
- @Test("RFC 8941: value with semicolons is not split by parser")
- func semicolonsNotSplitByParser() throws {
- // Semicolons in structured field values must not be misinterpreted
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Item: token;a=1;b=2;c=3\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- // The entire value including semicolons must be preserved as one string
- #expect(request.header("Example-Item") == "token;a=1;b=2;c=3")
- }
-
- @Test("RFC 8941: value with parentheses is not misinterpreted")
- func parenthesesNotMisinterpreted() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-List: (a b c), (d e)\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-List") == "(a b c), (d e)")
- }
-
- @Test("RFC 8941: value with equals signs is not misinterpreted")
- func equalsSignsNotMisinterpreted() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Dict: a=1, b=\"hello=world\", c=:YQ==:\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- // Equals signs in strings and base64 must be preserved
- #expect(request.header("Example-Dict") == "a=1, b=\"hello=world\", c=:YQ==:")
- }
-
- @Test("RFC 8941: value with question marks is not misinterpreted")
- func questionMarksNotMisinterpreted() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nExample-Dict: enabled=?1, disabled=?0\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- #expect(request.header("Example-Dict") == "enabled=?1, disabled=?0")
- }
-}
diff --git a/ios/Tests/GutenbergKitHTTPTests/RFC9110ConformanceTests.swift b/ios/Tests/GutenbergKitHTTPTests/RFC9110ConformanceTests.swift
deleted file mode 100644
index 450987556..000000000
--- a/ios/Tests/GutenbergKitHTTPTests/RFC9110ConformanceTests.swift
+++ /dev/null
@@ -1,235 +0,0 @@
-import Foundation
-import Testing
-@testable import GutenbergKitHTTP
-
-#if canImport(Network)
-import Network
-#endif
-
-/// Tests that require platform-specific APIs (URLRequest conversion, response
-/// serialization, server behavior) or conditional logic that cannot be expressed
-/// in the shared JSON fixture format. All pure parse-input → expected-output
-/// tests have been migrated to test-fixtures/http/request-parsing.json.
-@Suite("RFC 9110 Conformance")
-struct RFC9110ConformanceTests {
-
- // MARK: - Section 5.3 (Internal Dict Representation)
-
- @Test("RFC 9110 §5.3: duplicate headers preserve first occurrence's key casing")
- func duplicateHeadersPreserveFirstKeyCasing() throws {
- let parser = HTTPRequestParser("GET /wp/v2/posts HTTP/1.1\r\nX-Custom: first\r\nx-custom: second\r\nHost: localhost\r\n\r\n")
- let request = try #require(try parser.parseRequest())
-
- // The dict key should use the casing from the first occurrence
- #expect(request.headers["X-Custom"] == "first, second")
- #expect(request.headers["x-custom"] == nil)
- }
-
- // MARK: - Section 7.1 (Edge Cases)
-
- @Test("RFC 9110 §7.1: request target with empty path")
- func requestTargetWithEmptyPath() throws {
- // An empty request-target should be rejected or treated as "/"
- let parser = HTTPRequestParser("GET HTTP/1.1\r\nHost: localhost\r\n\r\n")
- let request = try? parser.parseRequest()
-
- // With split(maxSplits: 2) on "GET HTTP/1.1", this splits to ["GET", "", "HTTP/1.1"]
- // The target becomes ""
- if let request {
- #expect(request.method == "GET")
- }
- }
-
- // MARK: - Section 15 (Security - Request Splitting / Smuggling)
-
- @Test("RFC 9110 §15.6: LF in header value should not cause request splitting")
- func lfInHeaderValueNoRequestSplitting() throws {
- // A bare LF in a header value within a CRLF-terminated message
- // The parser should not split on bare LF
- let raw = "GET /wp/v2/posts HTTP/1.1\r\nX-Test: val\nue\r\nHost: localhost\r\n\r\n"
- let parser = HTTPRequestParser(raw)
- let request = try? parser.parseRequest()
-
- // Whether it parses or not, it shouldn't create a request smuggling vector
- if let request {
- #expect(request.header("Host") == "localhost")
- }
- }
-
- // MARK: - Response Serialization
-
- @Test("RFC 9110 §15.5.2: 401 response with WWW-Authenticate header serializes correctly")
- func wwwAuthenticateHeaderOn401() {
- let response = HTTPResponse(
- status: 401,
- headers: [("WWW-Authenticate", "Bearer")]
- )
- let serialized = String(data: response.serialized(), encoding: .utf8)!
-
- #expect(serialized.hasPrefix("HTTP/1.1 401 Unauthorized\r\n"))
- #expect(serialized.contains("WWW-Authenticate: Bearer\r\n"))
- }
-
- // MARK: - Section 15.5.9 (408 Request Timeout)
-
- #if canImport(Network)
- @Test("RFC 9110 §15.5.9: server sends 408 before closing on read timeout", .disabled("HTTPServer does not yet send 408 on idle timeout — needs NWConnection write support"), .timeLimit(.minutes(1)))
- func serverSends408OnReadTimeout() async throws {
- // Per RFC 9110 §15.5.9, a server that decides to close an idle connection
- // SHOULD send a 408 (Request Timeout) response.
- let server = try await HTTPServer.start(
- name: "timeout-test",
- port: nil,
- requiresAuthentication: false,
- readTimeout: .milliseconds(500)
- ) { _ in
- HTTPResponse(status: 200)
- }
- defer { server.stop() }
-
- // Connect and immediately read — don't send any request data.
- // URLSession won't work here (it always sends a request), so use raw sockets.
- let fd = socket(AF_INET, SOCK_STREAM, 0)
- #expect(fd >= 0, "Failed to create socket")
- defer { close(fd) }
-
- var addr = sockaddr_in()
- addr.sin_family = sa_family_t(AF_INET)
- addr.sin_port = server.port.bigEndian
- addr.sin_addr.s_addr = inet_addr("127.0.0.1")
-
- let connectResult = withUnsafePointer(to: &addr) {
- $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
- connect(fd, $0, socklen_t(MemoryLayout.size))
- }
- }
- #expect(connectResult == 0, "Failed to connect to server")
-
- // Set a 5-second read timeout so we don't block forever.
- var timeout = timeval(tv_sec: 5, tv_usec: 0)
- setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size))
-
- // Wait for the server to respond (it should send 408 within its 500ms timeout).
- var buffer = [UInt8](repeating: 0, count: 4096)
- let bytesRead = recv(fd, &buffer, buffer.count, 0)
-
- // The server should have sent a 408 response, not just closed silently.
- #expect(bytesRead > 0, "Server closed connection without sending a response")
-
- if bytesRead > 0 {
- let response = String(bytes: buffer[...allocate(capacity: 10)
- defer { buffer.deallocate() }
-
- // First read gets all bytes
- let n1 = stream.read(buffer, maxLength: 10)
- #expect(n1 == 2)
-
- // Second read returns 0 (at end)
- let n2 = stream.read(buffer, maxLength: 10)
- #expect(n2 == 0)
-
- stream.close()
- }
-
- @Test("fileSlice init throws for missing file")
- func fileSliceThrowsForMissingFile() {
- let url = FileManager.default.temporaryDirectory
- .appendingPathComponent("nonexistent-\(UUID().uuidString)")
- let body = RequestBody(fileURL: url, offset: 0, length: 10)
-
- #expect(throws: Error.self) {
- _ = try body.makeInputStream()
- }
- }
-
- @Test("fileSlice stream with zero length returns no data")
- func fileSliceZeroLength() throws {
- let contents = Data("ABCDE".utf8)
- let url = makeTemporaryFile(contents: contents)
- defer { try? FileManager.default.removeItem(at: url) }
-
- let body = RequestBody(fileURL: url, offset: 2, length: 0)
-
- let stream = try body.makeInputStream()
- #expect(readAll(stream) == Data())
- }
-
- @Test("fileSlice stream does not read beyond slice boundary")
- func fileSliceDoesNotReadBeyondBoundary() throws {
- let contents = Data("ABCDEFGHIJ".utf8)
- let url = makeTemporaryFile(contents: contents)
- defer { try? FileManager.default.removeItem(at: url) }
-
- // Slice is "CDE" (offset 2, length 3) — must not return "FGHIJ"
- let body = RequestBody(fileURL: url, offset: 2, length: 3)
-
- let stream = try body.makeInputStream()
- let result = readAll(stream)
- #expect(result == Data("CDE".utf8))
- #expect(result.count == 3)
- }
-
- @Test("fileSlice stream with binary data preserves all bytes")
- func fileSliceBinaryData() throws {
- // All byte values 0x00-0xFF
- let contents = Data(0...255)
- let url = makeTemporaryFile(contents: contents)
- defer { try? FileManager.default.removeItem(at: url) }
-
- let body = RequestBody(fileURL: url, offset: 100, length: 50)
-
- let stream = try body.makeInputStream()
- let result = readAll(stream)
- #expect(result == Data(100..<150))
- }
-
- @Test("multiple fileSlice streams from same file read independently")
- func fileSliceMultipleStreamsIndependent() throws {
- let contents = Data("ABCDEFGHIJKLMNOP".utf8)
- let url = makeTemporaryFile(contents: contents)
- defer { try? FileManager.default.removeItem(at: url) }
-
- let body1 = RequestBody(fileURL: url, offset: 0, length: 4)
- let body2 = RequestBody(fileURL: url, offset: 8, length: 4)
-
- let stream1 = try body1.makeInputStream()
- let stream2 = try body2.makeInputStream()
-
- #expect(readAll(stream1) == Data("ABCD".utf8))
- #expect(readAll(stream2) == Data("IJKL".utf8))
- }
-
- // MARK: - Equatable
-
- @Test("data-backed bodies with same data are equal")
- func dataEquality() {
- let data = Data("same".utf8)
- #expect(RequestBody(data: data) == RequestBody(data: data))
- }
-
- @Test("data-backed bodies with different data are not equal")
- func dataInequality() {
- #expect(RequestBody(data: Data("a".utf8)) != RequestBody(data: Data("b".utf8)))
- }
-
- @Test("file-backed bodies with same URL are equal")
- func fileEquality() {
- let url = URL(fileURLWithPath: "/tmp/same-file")
- #expect(RequestBody(fileURL: url) == RequestBody(fileURL: url))
- }
-
- @Test("data-backed and file-backed bodies are not equal")
- func dataVsFileInequality() {
- let data = Data("hello".utf8)
- let url = URL(fileURLWithPath: "/tmp/hello")
- #expect(RequestBody(data: data) != RequestBody(fileURL: url))
- }
-
- // MARK: - URLSession integration (file-slice via httpBodyStream)
-
- #if canImport(Network)
- @Test("fileSlice body sent via URLSession httpBodyStream delivers correct bytes")
- func fileSliceBodyStreamWorksWithURLSession() async throws {
- // Write a known payload to a temp file
- let payload = Data("The quick brown fox jumps over the lazy dog".utf8)
- let fileURL = makeTemporaryFile(contents: payload)
- defer { try? FileManager.default.removeItem(at: fileURL) }
-
- // Slice out "brown fox" (offset 10, length 9)
- let expectedSlice = Data("brown fox".utf8)
- let body = RequestBody(fileURL: fileURL, offset: 10, length: 9)
-
- // Start an echo server that returns the request body as the response
- let server = try await HTTPServer.start(
- name: "body-echo",
- requiresAuthentication: false
- ) { request in
- guard let body = request.parsed.body,
- let data = try? await body.data else {
- return HTTPResponse(status: 200, body: Data())
- }
- return HTTPResponse(status: 200, body: data)
- }
- defer { server.stop() }
-
- // Build a URLRequest using the same code path as the proxy:
- // this assigns body.makeInputStream() to request.httpBodyStream.
- let baseURL = URL(string: "http://127.0.0.1:\(server.port)")!
- let parsed = ParsedHTTPRequest.complete(
- method: "POST",
- target: "/echo",
- httpVersion: "HTTP/1.1",
- headers: ["Host": "localhost", "Content-Length": "9"],
- body: body
- )
- var request = try #require(parsed.urlRequest(relativeTo: baseURL))
- request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
-
- let (responseData, response) = try await URLSession.shared.data(for: request)
- let http = try #require(response as? HTTPURLResponse)
-
- #expect(http.statusCode == 200)
- // This assertion would fail with the old FileSliceInputStream subclass
- // because URLSession reads from the empty Data() superclass instead of
- // the overridden read(_:maxLength:) — resulting in an empty body.
- #expect(responseData == expectedSlice)
- }
- #endif
-
- // MARK: - Helpers
-
- private func makeTemporaryFile(contents: Data) -> URL {
- let url = FileManager.default.temporaryDirectory
- .appendingPathComponent("RequestBodyTests-\(UUID().uuidString)")
- FileManager.default.createFile(atPath: url.path, contents: contents)
- return url
- }
-
- private func readAll(_ stream: InputStream) -> Data {
- readAllWithBufferSize(stream, bufferSize: 1024)
- }
-
- private func readAllWithBufferSize(_ stream: InputStream, bufferSize: Int) -> Data {
- stream.open()
- defer { stream.close() }
-
- var data = Data()
- let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize)
- defer { buffer.deallocate() }
-
- // Use read() directly instead of hasBytesAvailable to avoid race
- // conditions with bound stream pairs, where data from the writer
- // thread may not have arrived yet when hasBytesAvailable is checked.
- while true {
- let bytesRead = stream.read(buffer, maxLength: bufferSize)
- if bytesRead <= 0 { break }
- data.append(buffer, count: bytesRead)
- }
-
- return data
- }
-}
diff --git a/ios/Tests/GutenbergKitHTTPTests/TempFileCleanupTests.swift b/ios/Tests/GutenbergKitHTTPTests/TempFileCleanupTests.swift
deleted file mode 100644
index 0ba71fafe..000000000
--- a/ios/Tests/GutenbergKitHTTPTests/TempFileCleanupTests.swift
+++ /dev/null
@@ -1,53 +0,0 @@
-#if canImport(Network)
-
-import Foundation
-import Testing
-@testable import GutenbergKitHTTP
-
-@Suite("Temp File Cleanup")
-struct TempFileCleanupTests {
-
- @Test("orphan cleanup skips registered (in-flight) files and removes orphans")
- func cleanupSkipsActiveFiles() throws {
- let dir = FileManager.default.temporaryDirectory
- .appendingPathComponent("GutenbergKitHTTP-cleanup-test-\(UUID().uuidString)")
- try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
- defer { try? FileManager.default.removeItem(at: dir) }
-
- let active = dir.appendingPathComponent("GutenbergKitHTTP-\(UUID().uuidString)")
- let orphan = dir.appendingPathComponent("GutenbergKitHTTP-\(UUID().uuidString)")
- #expect(FileManager.default.createFile(atPath: active.path, contents: Data("a".utf8)))
- #expect(FileManager.default.createFile(atPath: orphan.path, contents: Data("b".utf8)))
-
- // Mark `active` as backing an in-flight request, as a concurrently-running
- // server instance sharing this directory would.
- ActiveTempFiles.register(active.lastPathComponent)
- defer { ActiveTempFiles.unregister(active.lastPathComponent) }
-
- HTTPServer.cleanOrphanedTempFiles(in: dir)
-
- #expect(FileManager.default.fileExists(atPath: active.path), "registered (live) file must be preserved")
- #expect(!FileManager.default.fileExists(atPath: orphan.path), "unregistered orphan must be removed")
- }
-
- @Test("cleanup removes everything when nothing is registered (crash recovery)")
- func cleanupRemovesAllOrphansOnFreshProcess() throws {
- let dir = FileManager.default.temporaryDirectory
- .appendingPathComponent("GutenbergKitHTTP-cleanup-test-\(UUID().uuidString)")
- try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
- defer { try? FileManager.default.removeItem(at: dir) }
-
- let orphans = (0..<3).map { _ in dir.appendingPathComponent("GutenbergKitHTTP-\(UUID().uuidString)") }
- for url in orphans {
- #expect(FileManager.default.createFile(atPath: url.path, contents: Data("x".utf8)))
- }
-
- HTTPServer.cleanOrphanedTempFiles(in: dir)
-
- for url in orphans {
- #expect(!FileManager.default.fileExists(atPath: url.path))
- }
- }
-}
-
-#endif
diff --git a/ios/Tests/GutenbergKitHTTPTests/TestHelpers.swift b/ios/Tests/GutenbergKitHTTPTests/TestHelpers.swift
deleted file mode 100644
index 27396fc78..000000000
--- a/ios/Tests/GutenbergKitHTTPTests/TestHelpers.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-import Foundation
-@testable import GutenbergKitHTTP
-
-/// Reads the full contents of a `RequestBody` by streaming through its `InputStream`.
-///
-/// Shared across test files that need to verify body data. This version uses
-/// `hasBytesAvailable`, which is correct for data-backed and file-backed streams.
-/// For bound stream pairs, use a `while true` loop instead (see `ChunkedMultipartTests`).
-func readAll(_ body: RequestBody) throws -> Data {
- let stream = try body.makeInputStream()
- stream.open()
- defer { stream.close() }
-
- var data = Data()
- let bufferSize = 1024
- let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize)
- defer { buffer.deallocate() }
-
- while stream.hasBytesAvailable {
- let bytesRead = stream.read(buffer, maxLength: bufferSize)
- guard bytesRead > 0 else { break }
- data.append(buffer, count: bytesRead)
- }
-
- return data
-}
diff --git a/ios/Tests/GutenbergKitTests/EditorHTTPClientTests.swift b/ios/Tests/GutenbergKitTests/EditorHTTPClientTests.swift
index 0d49d9bfe..3871d64ed 100644
--- a/ios/Tests/GutenbergKitTests/EditorHTTPClientTests.swift
+++ b/ios/Tests/GutenbergKitTests/EditorHTTPClientTests.swift
@@ -184,16 +184,27 @@ struct EditorHTTPClientTests {
_ = try await uploadClient.performRaw(request)
let captured = try #require(spySession.lastCapturedRequest)
- // The short REST timeout must not bleed into uploads — the request keeps
- // its own (default) inactivity timeout instead.
+ // The short REST timeout must not bleed into uploads.
#expect(captured.timeoutInterval != restTimeout)
- #expect(captured.timeoutInterval == request.timeoutInterval)
// Auth and the shared session are still used.
#expect(captured.value(forHTTPHeaderField: "Authorization") == authHeader)
}
- @Test("uploadClient() preserves an explicit upload request timeout instead of clobbering it")
- func uploadClientPreservesExplicitTimeout() async throws {
+ @Test("uploadClient() raises URLRequest's 60s default so a slow server response can't orphan the attachment")
+ func uploadClientRaisesDefaultTimeout() async throws {
+ let spySession = SpyURLSession()
+ let client = EditorHTTPClient(urlSession: spySession, authHeader: "Bearer token")
+
+ let request = URLRequest(url: URL(string: "https://example.com/wp-json/wp/v2/media")!)
+ #expect(request.timeoutInterval == 60)
+ _ = try await client.uploadClient().performRaw(request)
+
+ let captured = try #require(spySession.lastCapturedRequest)
+ #expect(captured.timeoutInterval == EditorHTTPClient.uploadInactivityTimeout)
+ }
+
+ @Test("uploadClient() keeps an upload request's own timeout when it is longer than the floor")
+ func uploadClientPreservesLongerExplicitTimeout() async throws {
let spySession = SpyURLSession()
let client = EditorHTTPClient(
urlSession: spySession,
@@ -201,16 +212,27 @@ struct EditorHTTPClientTests {
requestTimeout: 15
)
- let uploadClient = client.uploadClient()
var request = URLRequest(url: URL(string: "https://example.com/wp-json/wp/v2/media")!)
- request.timeoutInterval = 120
+ request.timeoutInterval = EditorHTTPClient.uploadInactivityTimeout * 2
- _ = try await uploadClient.performRaw(request)
+ _ = try await client.uploadClient().performRaw(request)
// On the REST client the requestTimeout (15) would clobber this to 15;
- // the upload client leaves it alone.
+ // the upload client leaves a longer value alone.
let captured = try #require(spySession.lastCapturedRequest)
- #expect(captured.timeoutInterval == 120)
+ #expect(captured.timeoutInterval == EditorHTTPClient.uploadInactivityTimeout * 2)
+ }
+
+ @Test("The REST client leaves the request's timeout alone when it has no requestTimeout")
+ func restClientDoesNotApplyUploadFloor() async throws {
+ let spySession = SpyURLSession()
+ let client = EditorHTTPClient(urlSession: spySession, authHeader: "Bearer token")
+
+ let request = URLRequest(url: URL(string: "https://example.com/wp-json/wp/v2/posts")!)
+ _ = try await client.performRaw(request)
+
+ let captured = try #require(spySession.lastCapturedRequest)
+ #expect(captured.timeoutInterval == 60)
}
@Test("uploadClient() carries the request-observing delegate so uploads are observed too")
@@ -460,6 +482,84 @@ struct EditorHTTPClientTests {
#expect(userAgent.contains("macOS/"))
#endif
}
+
+ // MARK: - Sharing Tests
+
+ @Test("identical requests from clients on one session share a key")
+ func identicalRequestsShareAKey() async throws {
+ let session = SpyURLSession()
+ let request = URLRequest(url: URL(string: "https://example.com/wp-json/wp/v2/types")!)
+ let first = await EditorHTTPClient(urlSession: session, authHeader: "Bearer a").sharedRequest(for: request)
+ let second = await EditorHTTPClient(urlSession: session, authHeader: "Bearer a").sharedRequest(for: request)
+ #expect(first != nil)
+ #expect(first == second)
+ }
+
+ @Test("requests with other credentials, sessions, or timeouts don't share")
+ func requestsWithOtherCredentialsSessionsOrTimeoutsDontShare() async throws {
+ let session = SpyURLSession()
+ let request = URLRequest(url: URL(string: "https://example.com/wp-json/wp/v2/types")!)
+ let key = await EditorHTTPClient(urlSession: session, authHeader: "Bearer a").sharedRequest(for: request)
+
+ #expect(await EditorHTTPClient(urlSession: session, authHeader: "Bearer b").sharedRequest(for: request) != key)
+ #expect(await EditorHTTPClient(urlSession: SpyURLSession(), authHeader: "Bearer a").sharedRequest(for: request) != key)
+ #expect(await EditorHTTPClient(urlSession: session, authHeader: "Bearer a", requestTimeout: 5).sharedRequest(for: request) != key)
+ }
+
+ @Test("only safe requests without a body, from a client no delegate watches, are shared")
+ func onlySafeUnwatchedRequestsAreShared() async throws {
+ let session = SpyURLSession()
+ let client = EditorHTTPClient(urlSession: session, authHeader: "Bearer a")
+ let url = URL(string: "https://example.com/wp-json/wp/v2/settings")!
+
+ #expect(await client.sharedRequest(for: URLRequest(method: .OPTIONS, url: url)) != nil)
+ #expect(await client.sharedRequest(for: URLRequest(method: .POST, url: url)) == nil)
+
+ var withBody = URLRequest(url: url)
+ withBody.httpBody = Data("{}".utf8)
+ #expect(await client.sharedRequest(for: withBody) == nil)
+
+ let watched = EditorHTTPClient(urlSession: session, authHeader: "Bearer a", delegate: SpyHTTPClientDelegate())
+ #expect(await watched.sharedRequest(for: URLRequest(url: url)) == nil)
+ }
+
+ @Test("a request that asks to skip the cache goes out alone")
+ func aRequestThatSkipsTheCacheGoesOutAlone() async throws {
+ let client = EditorHTTPClient(urlSession: SpyURLSession(), authHeader: "Bearer a")
+ var request = URLRequest(url: URL(string: "https://example.com/wp-json/wp/v2/posts/5")!)
+
+ let freshAnswerPolicies: [URLRequest.CachePolicy] = [
+ .reloadIgnoringLocalCacheData, .reloadIgnoringLocalAndRemoteCacheData, .reloadRevalidatingCacheData,
+ ]
+ for policy in freshAnswerPolicies {
+ request.cachePolicy = policy
+ #expect(await client.sharedRequest(for: request) == nil)
+ }
+
+ request.cachePolicy = .returnCacheDataElseLoad
+ #expect(await client.sharedRequest(for: request) != nil)
+ }
+
+ @Test("identical requests in flight go out once")
+ func identicalRequestsInFlightGoOutOnce() async throws {
+ let session = ParkedURLSession()
+ defer { session.release() }
+ let request = URLRequest(url: URL(string: "https://example.com/wp-json/wp/v2/types")!)
+ let clients = [
+ EditorHTTPClient(urlSession: session, authHeader: "Bearer a"),
+ EditorHTTPClient(urlSession: session, authHeader: "Bearer a"),
+ ]
+ let key = try #require(await clients[0].sharedRequest(for: request))
+
+ let callers = clients.map { client in Task { try await client.perform(request) } }
+ try await waitUntil { EditorHTTPClient.inFlightRequests.waiterCount(for: key) == 2 }
+
+ session.release() // fails the parked request, for every caller waiting on it
+ for caller in callers {
+ await #expect(throws: URLError.self) { try await caller.value }
+ }
+ #expect(session.requestCount == 1)
+ }
}
fileprivate extension EditorResponseData {
diff --git a/ios/Tests/GutenbergKitTests/EditorViewControllerLifecycleTests.swift b/ios/Tests/GutenbergKitTests/EditorViewControllerLifecycleTests.swift
index a672b38d1..4807715a6 100644
--- a/ios/Tests/GutenbergKitTests/EditorViewControllerLifecycleTests.swift
+++ b/ios/Tests/GutenbergKitTests/EditorViewControllerLifecycleTests.swift
@@ -35,33 +35,33 @@ struct EditorViewControllerLifecycleTests: MakesTestFixtures {
#expect(!cancelled)
}
- /// Why `deinit` can't cancel the fetch: `await self?.prepareEditor()` keeps the
- /// editor alive until the load finishes, so `deinit` only runs once it's over.
+ /// The loader owns the fetch and reaches the editor only weakly, so a released
+ /// editor is freed while its fetch is still parked — and the fetch keeps running.
@MainActor
- @Test("the in-flight fetch keeps the editor alive until it finishes")
- func theInFlightFetchKeepsTheEditorAlive() async throws {
+ @Test("releasing the editor mid-fetch frees it, and leaves the fetch running")
+ func releasingTheEditorMidFetchFreesIt() async throws {
let session = ParkedURLSession()
let configuration = makeIsolatedConfiguration()
defer { removeStorage(for: configuration) }
- // Safety net if a throw skips the `release()` below; calling it twice is fine.
defer { session.release() }
var editor: EditorViewController? = makeEditor(configuration: configuration, session: session)
weak let releasedEditor = editor
- _ = editor?.view
+ _ = editor?.view // triggers `viewDidLoad`, which starts the fetch
try await session.waitUntilStarted()
+ // Polled rather than checked once, so it doesn't depend on exactly when UIKit
+ // lets go. The fetch stays parked throughout, so it can't be what lets go.
editor = nil
- try await Task.sleep(for: .milliseconds(250))
- #expect(releasedEditor != nil, "the fetch should hold the editor alive")
-
- session.release()
let clock = ContinuousClock()
- let deadline = clock.now + .seconds(10)
+ let deadline = clock.now + .seconds(2)
while releasedEditor != nil && clock.now < deadline {
try await Task.sleep(for: .milliseconds(20))
}
- #expect(releasedEditor == nil, "the editor should be freed once the fetch ends")
+ #expect(releasedEditor == nil, "the fetch should not hold the editor")
+
+ let cancelled = await session.waitUntilCancelled(timeout: .milliseconds(250))
+ #expect(!cancelled, "freeing the editor should not cancel the fetch")
}
/// A unique `siteId` per call, so no earlier run's cache can serve the fetch.
@@ -92,70 +92,4 @@ struct EditorViewControllerLifecycleTests: MakesTestFixtures {
}
}
-/// A `URLSessionProtocol` whose requests hang until `release()`, so a fetch stays in
-/// flight for as long as the test needs. Records whether any request was cancelled.
-private final class ParkedURLSession: URLSessionProtocol, @unchecked Sendable {
- private let lock = NSLock()
- private var started = false
- private var cancelled = false
- private var released = false
-
- private var isStarted: Bool { lock.withLock { started } }
- private var isCancelled: Bool { lock.withLock { cancelled } }
- private var isReleased: Bool { lock.withLock { released } }
-
- func data(for request: URLRequest) async throws -> (Data, URLResponse) {
- try await park()
- }
-
- func download(for request: URLRequest, delegate: (any URLSessionTaskDelegate)?) async throws -> (URL, URLResponse) {
- try await park()
- }
-
- /// Makes every parked request fail, so the fetch ends. Always call it: a request
- /// left parked keeps its editor alive for the rest of the run.
- func release() {
- lock.withLock { released = true }
- }
-
- /// Suspends until `release()` or until the calling task is cancelled.
- /// `Never` because every exit throws, so it fits both methods' return types.
- private func park() async throws -> Never {
- lock.withLock { started = true }
- while !isReleased {
- do {
- try await Task.sleep(for: .milliseconds(20))
- } catch {
- lock.withLock { cancelled = true }
- throw URLError(.cancelled)
- }
- }
- throw URLError(.networkConnectionLost)
- }
-
- func waitUntilStarted(timeout: Duration = .seconds(10)) async throws {
- let clock = ContinuousClock()
- let deadline = clock.now + timeout
- while clock.now < deadline {
- if isStarted { return }
- try await Task.sleep(for: .milliseconds(20))
- }
- throw ParkedURLSessionTimeout.requestNeverStarted
- }
-
- func waitUntilCancelled(timeout: Duration) async -> Bool {
- let clock = ContinuousClock()
- let deadline = clock.now + timeout
- while clock.now < deadline {
- if isCancelled { return true }
- try? await Task.sleep(for: .milliseconds(20))
- }
- return isCancelled
- }
-}
-
-private enum ParkedURLSessionTimeout: Error {
- case requestNeverStarted
-}
-
#endif
diff --git a/ios/Tests/GutenbergKitTests/Helpers/ParkedURLSession.swift b/ios/Tests/GutenbergKitTests/Helpers/ParkedURLSession.swift
new file mode 100644
index 000000000..9a583309f
--- /dev/null
+++ b/ios/Tests/GutenbergKitTests/Helpers/ParkedURLSession.swift
@@ -0,0 +1,77 @@
+import Foundation
+@testable import GutenbergKit
+
+/// A `URLSessionProtocol` whose requests never finish until the test lets them, so
+/// work started against it stays in flight for as long as the test needs, and which
+/// records whether the task waiting on a request was cancelled.
+final class ParkedURLSession: URLSessionProtocol, @unchecked Sendable {
+ private let lock = NSLock()
+ private var started = false
+ private var cancelled = false
+ private var released = false
+ private var requests = 0
+
+ private var isStarted: Bool { lock.withLock { started } }
+ private var isCancelled: Bool { lock.withLock { cancelled } }
+ private var isReleased: Bool { lock.withLock { released } }
+
+ /// How many requests have been made against this session.
+ var requestCount: Int { lock.withLock { requests } }
+
+ func data(for request: URLRequest) async throws -> (Data, URLResponse) {
+ try await park()
+ }
+
+ func download(for request: URLRequest, delegate: (any URLSessionTaskDelegate)?) async throws -> (URL, URLResponse) {
+ try await park()
+ }
+
+ /// Lets every parked request fail, so the work waiting on them — and the task
+ /// running it — finishes. Always call this: a request left parked stays in
+ /// flight for the rest of the run.
+ func release() {
+ lock.withLock { released = true }
+ }
+
+ /// Suspends until `release()` or until the calling task is cancelled.
+ /// `Never` because every exit throws — it satisfies both return types.
+ private func park() async throws -> Never {
+ lock.withLock {
+ started = true
+ requests += 1
+ }
+ while !isReleased {
+ do {
+ try await Task.sleep(for: .milliseconds(20))
+ } catch {
+ lock.withLock { cancelled = true }
+ throw URLError(.cancelled)
+ }
+ }
+ throw URLError(.networkConnectionLost)
+ }
+
+ func waitUntilStarted(timeout: Duration = patientTimeout) async throws {
+ let clock = ContinuousClock()
+ let deadline = clock.now + timeout
+ while clock.now < deadline {
+ if isStarted { return }
+ try await Task.sleep(for: .milliseconds(20))
+ }
+ throw ParkedURLSessionTimeout.requestNeverStarted
+ }
+
+ func waitUntilCancelled(timeout: Duration) async -> Bool {
+ let clock = ContinuousClock()
+ let deadline = clock.now + timeout
+ while clock.now < deadline {
+ if isCancelled { return true }
+ try? await Task.sleep(for: .milliseconds(20))
+ }
+ return isCancelled
+ }
+}
+
+enum ParkedURLSessionTimeout: Error {
+ case requestNeverStarted
+}
diff --git a/ios/Tests/GutenbergKitTests/InFlightTasksTests.swift b/ios/Tests/GutenbergKitTests/InFlightTasksTests.swift
new file mode 100644
index 000000000..72376d2b2
--- /dev/null
+++ b/ios/Tests/GutenbergKitTests/InFlightTasksTests.swift
@@ -0,0 +1,228 @@
+import Foundation
+import Testing
+
+@testable import GutenbergKit
+
+@Suite("InFlightTasks", .timeLimit(.minutes(1)))
+struct InFlightTasksTests {
+
+ /// Each test's own table, so tests running in parallel can't meet in it.
+ private let tasks = InFlightTasks()
+
+ // MARK: - Sharing
+
+ @Test("callers for the same key share one task, and all hear its progress")
+ func callersForTheSameKeyShareOneTask() async throws {
+ let work = ParkedWork()
+ let (first, second) = (ProgressTracker(), ProgressTracker())
+ let a = startCaller(for: "key", running: work, progress: first)
+ let b = startCaller(for: "key", running: work, progress: second)
+ try await waitUntil { tasks.waiterCount(for: "key") == 2 }
+ try await waitUntil { !first.updates.isEmpty && !second.updates.isEmpty }
+
+ work.release()
+ #expect(try await a.value == ParkedWork.value)
+ #expect(try await b.value == ParkedWork.value)
+ #expect(work.runs == 1)
+ }
+
+ @Test("callers for different keys run separately")
+ func callersForDifferentKeysRunSeparately() async throws {
+ let work = ParkedWork()
+ let one = startCaller(for: "one", running: work)
+ let other = startCaller(for: "other", running: work)
+ try await waitUntil { work.runs == 2 }
+
+ work.release()
+ #expect(try await one.value == ParkedWork.value)
+ #expect(try await other.value == ParkedWork.value)
+ }
+
+ @Test("a failed task fails every caller waiting on it")
+ func aFailedTaskFailsEveryCaller() async throws {
+ let work = ParkedWork()
+ let a = startCaller(for: "key", running: work)
+ let b = startCaller(for: "key", running: work)
+ try await waitUntil { tasks.waiterCount(for: "key") == 2 }
+
+ work.release(throwing: URLError(.timedOut))
+ await #expect(throws: URLError.self) { try await a.value }
+ await #expect(throws: URLError.self) { try await b.value }
+ }
+
+ // MARK: - Cancellation
+
+ @Test("cancelling a caller ends its wait at once, and leaves the task running for the rest")
+ func cancellingACallerLeavesTheTaskRunning() async throws {
+ let work = ParkedWork()
+ let leaving = startCaller(for: "key", running: work)
+ let staying = startCaller(for: "key", running: work)
+ try await waitUntil { tasks.waiterCount(for: "key") == 2 }
+
+ leaving.cancel()
+ // Returns while the task is still parked — it doesn't wait for it.
+ await #expect(throws: CancellationError.self) { try await leaving.value }
+ #expect(!work.wasCancelled)
+
+ work.release()
+ #expect(try await staying.value == ParkedWork.value)
+ }
+
+ @Test("the last caller leaving cancels the task, and the next caller starts afresh")
+ func theLastCallerLeavingCancelsTheTask() async throws {
+ let work = ParkedWork()
+ let first = startCaller(for: "key", running: work)
+ try await waitUntil { work.runs == 1 }
+
+ first.cancel()
+ await #expect(throws: CancellationError.self) { try await first.value }
+ try await waitUntil { work.wasCancelled }
+
+ let next = startCaller(for: "key", running: work)
+ try await waitUntil { work.runs == 2 }
+ work.release()
+ #expect(try await next.value == ParkedWork.value)
+ }
+
+ @Test("a caller that has left hears no more progress, even from a report already under way")
+ func aCallerThatHasLeftHearsNoMoreProgress() async throws {
+ let work = ParkedWork()
+ let gate = ProgressGate()
+ let leaver = ProgressTracker()
+ let staying = startCaller(for: "key", running: work, onProgress: { await gate.pass($0) })
+ try await waitUntil { tasks.waiterCount(for: "key") == 1 }
+ let leaving = startCaller(for: "key", running: work, progress: leaver)
+ try await waitUntil { !leaver.updates.isEmpty }
+
+ // Hold a report in the staying caller's callback. The leaving caller had already heard
+ // one, so it is on this report's list too, waiting its turn.
+ gate.close()
+ try await waitUntil { gate.isHolding }
+ leaving.cancel()
+ await #expect(throws: CancellationError.self) { try await leaving.value }
+ let heard = leaver.count
+
+ // Let the held report finish and the next one start, so the leaving caller's turn is past.
+ let passes = gate.passes
+ gate.open()
+ try await waitUntil { gate.passes >= passes + 2 }
+ #expect(leaver.count == heard, "the leaving caller should hear nothing once it has left")
+
+ work.release()
+ #expect(try await staying.value == ParkedWork.value)
+ }
+
+ // MARK: - Priority
+
+ @Test("a caller joining at a higher priority raises the task to match")
+ func aHigherPriorityCallerRaisesTheTask() async throws {
+ guard #available(iOS 26, macOS 26, *) else { return }
+ let work = ParkedWork()
+ let low = startCaller(for: "key", running: work, priority: .utility)
+ try await waitUntil { work.runs == 1 }
+ #expect(work.priority.rawValue < TaskPriority.userInitiated.rawValue)
+
+ let high = startCaller(for: "key", running: work, priority: .userInitiated)
+ try await waitUntil { work.priority.rawValue >= TaskPriority.userInitiated.rawValue }
+
+ work.release()
+ #expect(try await low.value == ParkedWork.value)
+ #expect(try await high.value == ParkedWork.value)
+ }
+
+ // MARK: - Helpers
+
+ /// A caller waiting on `key`, which runs `work` if nothing is in flight for it yet. It hears
+ /// progress through `onProgress`, or else records it in `progress`.
+ private func startCaller(
+ for key: String,
+ running work: ParkedWork,
+ priority: TaskPriority? = nil,
+ progress: ProgressTracker? = nil,
+ onProgress: EditorProgressCallback? = nil
+ ) -> Task {
+ let callback: EditorProgressCallback? = onProgress ?? progress.map { tracker in
+ { @Sendable (update: EditorProgress) async in tracker.append(update) }
+ }
+ return Task(priority: priority) { [tasks] in
+ try await tasks.value(for: key, progress: callback) { report in
+ try await work.run(reporting: report)
+ }
+ }
+ }
+}
+
+/// A progress callback the test can close, holding whichever report reaches it until it reopens.
+private final class ProgressGate: @unchecked Sendable {
+ private let lock = NSLock()
+ private var closed = false
+ private var holding = false
+ private var passCount = 0
+
+ /// Whether a report is held here right now.
+ var isHolding: Bool { lock.withLock { holding } }
+
+ /// How many reports have been let through.
+ var passes: Int { lock.withLock { passCount } }
+
+ func close() { lock.withLock { closed = true } }
+ func open() { lock.withLock { closed = false } }
+
+ func pass(_ progress: EditorProgress) async {
+ while lock.withLock({ holding = closed; return closed }) {
+ try? await Task.sleep(for: .milliseconds(5))
+ }
+ lock.withLock {
+ holding = false
+ passCount += 1
+ }
+ }
+}
+
+/// Work that runs until released: it counts its runs, reports progress while it waits, and
+/// records whether it was cancelled.
+private final class ParkedWork: @unchecked Sendable {
+ static let value = 42
+
+ private let lock = NSLock()
+ private var runCount = 0
+ private var cancelled = false
+ private var released = false
+ private var failure: (any Error)?
+ private var latestPriority = TaskPriority.medium
+
+ var runs: Int { lock.withLock { runCount } }
+ var wasCancelled: Bool { lock.withLock { cancelled } }
+
+ /// The priority the latest run was going at when it last checked.
+ var priority: TaskPriority { lock.withLock { latestPriority } }
+
+ /// Lets every run finish: with `failure` if there is one, or with ``value``.
+ func release(throwing failure: (any Error)? = nil) {
+ lock.withLock {
+ self.failure = failure
+ released = true
+ }
+ }
+
+ func run(reporting report: EditorProgressCallback) async throws -> Int {
+ lock.withLock {
+ latestPriority = Task.currentPriority
+ runCount += 1
+ }
+ while !lock.withLock({ released }) {
+ lock.withLock { latestPriority = Task.currentPriority }
+ await report(EditorProgress(completed: 1, total: 100))
+ do {
+ try await Task.sleep(for: .milliseconds(10))
+ } catch {
+ lock.withLock { cancelled = true }
+ throw error
+ }
+ }
+ if let failure = lock.withLock({ failure }) {
+ throw failure
+ }
+ return Self.value
+ }
+}
diff --git a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift
index 98d66e49b..61b6cf213 100644
--- a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift
+++ b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift
@@ -15,6 +15,9 @@ import Testing
/// call: not because UIKit can't report a teardown, but because it can't report whether
/// one is permanent. A host may re-present or re-attach the same editor, and the call is
/// terminal, so guessing wrong disables media in an editor that survived.
+///
+/// Also runs the page side of the native upload protocol inside the editor's own web
+/// view, so the scheme handler is exercised by real WebKit.
@Suite("EditorViewController media teardown")
struct EditorViewControllerMediaTeardownTests: MakesTestFixtures {
static let testSiteURL = URL(string: "https://test.example.com")!
@@ -57,30 +60,21 @@ struct EditorViewControllerMediaTeardownTests: MakesTestFixtures {
#expect(weakEditor == nil, "EditorViewController leaked — nothing here retains it")
}
- // MARK: - Which handlers bring the server up
+ // MARK: - Which handlers enable native uploads
- /// The regression this pins: `startUploadServer()` reads "did the host supply a
- /// handler" twice — once before starting, once after the bind returns — and the two
- /// reads drifted. The first gained `mediaUploader`, the second kept checking the
- /// processor alone, so an uploader-only host bound a listener and then immediately
- /// stopped it. `uploadServer` stayed nil, the page was advertised `nativeUploadPort:
- /// nil`, and `api-fetch.js` fell through to the plain WebView path — so the host's
- /// `upload(_:)` was never called for any file, with nothing logged.
- ///
/// Android pins the same gate (`GutenbergViewUploadServerTest`, "the upload server
- /// starts for an uploader with no processor"); iOS had no equivalent, which is why the
- /// drift survived three commits with a green suite.
+ /// starts for an uploader with no processor"). An uploader-only host once had its
+ /// native upload path silently disabled on iOS, so each combination is pinned.
@MainActor
@Test(
- "the upload server starts for whichever handler the host supplied",
- .enabled(if: canBindUploadServer),
+ "native uploads are enabled for whichever handler the host supplied",
arguments: [
("uploader only", false, true),
("processor only", true, false),
("both", true, true)
]
)
- func uploadServerStartsForAnyHandler(_ label: String, processor: Bool, uploader: Bool) async {
+ func nativeUploadsEnabledForAnyHandler(_ label: String, processor: Bool, uploader: Bool) {
let editor = EditorViewController(
configuration: makeConfiguration(),
mediaProcessor: processor ? StandaloneProcessor() : nil,
@@ -88,19 +82,240 @@ struct EditorViewControllerMediaTeardownTests: MakesTestFixtures {
)
defer { editor.stopMediaHandling() }
- await editor.startUploadServer()
+ #expect(editor.mediaUploadSchemeHandler.isEnabled, "\(label): the host's media handling would never run")
+ #expect(editor.webView.configuration.urlSchemeHandler(forURLScheme: MediaUploadSchemeHandler.scheme) != nil)
+ }
+
+ @MainActor
+ @Test("no handler leaves native uploads disabled")
+ func noHandlerLeavesNativeUploadsDisabled() {
+ let editor = EditorViewController(configuration: makeConfiguration())
+
+ #expect(!editor.mediaUploadSchemeHandler.isEnabled, "enabled native uploads with nothing to route them through")
+ }
- #expect(editor.uploadServer != nil, "\(label): no upload server, so the host's media handling never runs")
+ @MainActor
+ @Test("a processor without site credentials leaves native uploads disabled")
+ func processorWithoutCredentials() {
+ let configuration = makeConfigurationBuilder().setAuthHeader("").build()
+ let editor = EditorViewController(configuration: configuration, mediaProcessor: StandaloneProcessor())
+
+ #expect(!editor.mediaUploadSchemeHandler.isEnabled)
}
@MainActor
- @Test("no handler leaves the upload server down", .enabled(if: canBindUploadServer))
- func noHandlerLeavesServerDown() async {
+ @Test("stopMediaHandling disables native uploads")
+ func stopMediaHandlingDisablesNativeUploads() {
+ let editor = EditorViewController(configuration: makeConfiguration(), mediaUploader: InertUploader())
+
+ editor.stopMediaHandling()
+
+ #expect(!editor.mediaUploadSchemeHandler.isEnabled)
+ #expect(editor.mediaUploader == nil)
+ }
+
+ // MARK: - Uploads from the editor's own web view
+
+ @MainActor
+ @Test("the page uploads a file in chunks, and the host's uploader receives it intact")
+ func pageUploadsInChunks() async throws {
+ let uploader = RecordingUploader()
+ let editor = EditorViewController(configuration: makeConfiguration(), mediaUploader: uploader)
+ defer { editor.stopMediaHandling() }
+ try await loadBlankPage(in: editor)
+
+ // Larger than two chunks, so the offsets and the final short chunk are exercised.
+ let size = 9 * 1024 * 1024 + 123
+ let result = try await runUpload(in: editor, size: size)
+
+ #expect(result["status"] as? Int == 201)
+ let received = try #require(uploader.receivedContents)
+ #expect(received.count == size)
+ #expect(received.hasSameBytes(as: Self.pattern(count: size)), "the file was reassembled out of order or short")
+ #expect(uploader.received?.filename == "clip.bin")
+ #expect(uploader.received?.fields == [MediaUploadField(name: "post", value: "7")])
+ #expect(uploader.received?.query == "?_embed")
+ }
+
+ @MainActor
+ @Test("the page can read the attachment ID off a failed upload, so core can recover it")
+ func attachmentIDIsExposedToThePage() async throws {
+ let session = RelayingURLSession(statusCode: 500, headers: ["x-wp-upload-attachment-id": "42"])
+ let editor = EditorViewController(
+ configuration: makeConfiguration(),
+ mediaProcessor: StandaloneProcessor(),
+ httpClient: EditorHTTPClient(urlSession: session, authHeader: "Bearer test-token")
+ )
+ defer { editor.stopMediaHandling() }
+ try await loadBlankPage(in: editor)
+
+ let result = try await runUpload(in: editor, size: 16)
+
+ #expect(result["status"] as? Int == 500)
+ #expect(result["attachmentId"] as? String == "42")
+ #expect(session.requestCount == 1)
+ }
+
+ @MainActor
+ @Test("after stopMediaHandling the page is told to upload through the web view")
+ func stoppedEditorAnswers503() async throws {
+ let editor = EditorViewController(configuration: makeConfiguration(), mediaUploader: InertUploader())
+ try await loadBlankPage(in: editor)
+ editor.stopMediaHandling()
+
+ let result = try await runUpload(in: editor, size: 16)
+
+ #expect(result["beginStatus"] as? Int == 503)
+ }
+
+ // MARK: - Media from the block inserter
+
+ @MainActor
+ @Test("an imported file is offered to the page as a file, and its item says so", .enabled(if: NativeFileInput.isSupported))
+ func importedFilesAreOffered() async throws {
let editor = EditorViewController(configuration: makeConfiguration())
+ let (media, fileURL) = try await importTestVideo()
- await editor.startUploadServer()
+ let (item, file) = try editor.javaScriptMediaItem(for: media)
+ let dictionary = try #require(item as? [String: Any])
- #expect(editor.uploadServer == nil, "started a server with nothing to route through it")
+ #expect(file == fileURL)
+ #expect(dictionary["nativeFile"] as? Bool == true)
+ #expect(dictionary["type"] as? String == "video/quicktime")
+ #expect(dictionary["url"] as? String == media.url)
+ }
+
+ @MainActor
+ @Test("media the editor did not import goes to the page as it is")
+ func otherMediaIsNotOffered() async throws {
+ let editor = EditorViewController(configuration: makeConfiguration())
+
+ let library = try editor.javaScriptMediaItem(
+ for: MediaInfo(id: 42, url: "https://example.com/a.jpg", type: "image/jpeg")
+ )
+ let remote = try editor.javaScriptMediaItem(
+ for: MediaInfo(url: "https://example.com/a.jpg", type: "image/jpeg")
+ )
+ let missing = try editor.javaScriptMediaItem(
+ for: MediaInfo(url: "gbk-media-file:///Uploads/gone/IMG_0001.MOV", type: "video/quicktime")
+ )
+
+ for (item, file) in [library, remote, missing] {
+ #expect(file == nil)
+ #expect((item as? [String: Any])?["nativeFile"] == nil)
+ }
+ }
+
+ @MainActor
+ @Test("the page's file input gets the offered file, read from disk", .enabled(if: NativeFileInput.isSupported))
+ func pageReceivesOfferedFiles() async throws {
+ let editor = EditorViewController(configuration: makeConfiguration())
+ try await loadBlankPage(in: editor)
+ let (_, fileURL) = try await importTestVideo()
+
+ let result = try await editor.nativeFileInput.offer([fileURL], to: editor.webView) {
+ try await editor.webView.callAsyncJavaScript(
+ """
+ const files = await new Promise((resolve, reject) => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.multiple = true;
+ input.addEventListener('change', () => resolve(Array.from(input.files)));
+ input.addEventListener('cancel', () => reject(new Error('cancelled')));
+ document.body.appendChild(input);
+ input.click();
+ });
+ const bytes = new Uint8Array(await files[0].slice(0, 4).arrayBuffer());
+ return { count: files.length, name: files[0].name, size: files[0].size, type: files[0].type, first: Array.from(bytes) };
+ """,
+ arguments: [:],
+ in: nil,
+ contentWorld: .page
+ )
+ }
+ let file = try #require(result as? [String: Any])
+
+ #expect(file["count"] as? Int == 1)
+ #expect(file["name"] as? String == fileURL.lastPathComponent)
+ #expect(file["size"] as? Int == 32)
+ #expect(file["type"] as? String == "video/quicktime")
+ #expect(file["first"] as? [Int] == [1, 1, 1, 1])
+ #expect(editor.webView.uiDelegate == nil, "the offer outlived the insertion")
+ }
+
+ /// Imports a small file the way the camera path does.
+ private func importTestVideo() async throws -> (MediaInfo, URL) {
+ let source = try makeTemporaryFile(Data(repeating: 1, count: 32), named: "clip-\(UUID().uuidString).MOV")
+ let media = try await MediaFileManager.shared.importFile(at: source)
+ let url = try #require(media.url.flatMap(URL.init(string:)))
+ return (media, try #require(MediaFileManager.fileURL(for: url)))
+ }
+
+ /// Loads an empty `file://` page — the editor's own origin — into the editor's web view.
+ @MainActor
+ private func loadBlankPage(in editor: EditorViewController) async throws {
+ let directory = URL.randomTemporaryDirectory
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let page = directory.appending(component: "index.html")
+ try Data("upload test".utf8).write(to: page)
+ editor.webView.loadFileURL(page, allowingReadAccessTo: directory)
+
+ // Longer than `patientTimeout`: on the CI machine a web view takes about a minute to load
+ // its first page, however small. Three runs there each had it answering 60 to 66 seconds in.
+ var isLoaded = false
+ let deadline = ContinuousClock.now + .seconds(180)
+ while !isLoaded && ContinuousClock.now < deadline {
+ let readyState = try? await editor.webView.evaluateJavaScript("document.readyState")
+ isLoaded = !editor.webView.isLoading && readyState as? String == "complete"
+ if !isLoaded {
+ try await Task.sleep(for: .milliseconds(10))
+ }
+ }
+
+ // Stops the test here: carrying on in a page that never loaded fails it again, further
+ // on, with a JavaScript error that says nothing about why.
+ try #require(isLoaded, "the test page never finished loading")
+ }
+
+ /// Runs the page side of the upload protocol, as `nativeMediaUploadMiddleware` does.
+ @MainActor
+ private func runUpload(in editor: EditorViewController, size: Int) async throws -> [String: Any] {
+ let result = try await editor.webView.callAsyncJavaScript(
+ """
+ const base = 'gbk-upload://upload';
+ const bytes = new Uint8Array(size);
+ for (let i = 0; i < size; i++) bytes[i] = i % 251;
+ const file = new File([bytes], 'clip.bin', { type: 'application/octet-stream' });
+ const begin = await fetch(`${base}/sessions`, {
+ method: 'POST',
+ body: JSON.stringify({ filename: file.name, mimeType: file.type, size: file.size }),
+ });
+ if (!begin.ok) return { beginStatus: begin.status };
+ const { id } = await begin.json();
+ const chunkSize = 4 * 1024 * 1024;
+ for (let offset = 0; offset < file.size; offset += chunkSize) {
+ const chunk = await file.slice(offset, offset + chunkSize).arrayBuffer();
+ const response = await fetch(`${base}/sessions/${id}/chunks?offset=${offset}`, { method: 'POST', body: chunk });
+ if (!response.ok) return { chunkStatus: response.status, offset };
+ }
+ const finish = await fetch(`${base}/sessions/${id}/finish`, {
+ method: 'POST',
+ body: JSON.stringify({ fields: [{ name: 'post', value: '7' }], query: '?_embed' }),
+ });
+ return {
+ status: finish.status,
+ attachmentId: finish.headers.get('x-wp-upload-attachment-id'),
+ };
+ """,
+ arguments: ["size": size],
+ in: nil,
+ contentWorld: .page
+ )
+ return try #require(result as? [String: Any])
+ }
+
+ private static func pattern(count: Int) -> Data {
+ Data((0.. Data { Data() }
}
-/// Whether `HTTPServer` can bind here — it cannot in some sandboxes, and these tests
-/// assert on a real listener.
-private let canBindUploadServer: Bool = {
- let result = UnsafeSendableBox(false)
- let semaphore = DispatchSemaphore(value: 0)
- Task {
- if let server = try? await MediaUploadServer.start() {
- server.stop()
- result.value = true
+/// Answers every request with a canned status and headers, draining the body first as
+/// URLSession would.
+private final class RelayingURLSession: URLSessionProtocol, @unchecked Sendable {
+ private let statusCode: Int
+ private let headers: [String: String]
+ private let lock = NSLock()
+ private var count = 0
+
+ var requestCount: Int { lock.withLock { count } }
+
+ init(statusCode: Int, headers: [String: String]) {
+ self.statusCode = statusCode
+ self.headers = headers
+ }
+
+ func data(for request: URLRequest) async throws -> (Data, URLResponse) {
+ if let stream = request.httpBodyStream {
+ _ = readAllFromStream(stream)
}
- semaphore.signal()
+ lock.withLock { count += 1 }
+ let response = HTTPURLResponse(url: request.url!, statusCode: statusCode, httpVersion: "HTTP/1.1", headerFields: headers)!
+ return (Data(#"{"code":"rest_upload_sideload_error"}"#.utf8), response)
}
- semaphore.wait()
- return result.value
-}()
-private final class UnsafeSendableBox: @unchecked Sendable {
- var value: T
- init(_ value: T) { self.value = value }
+ func download(for request: URLRequest, delegate: (any URLSessionTaskDelegate)?) async throws -> (URL, URLResponse) {
+ throw URLError(.unsupportedURL)
+ }
}
diff --git a/ios/Tests/GutenbergKitTests/Media/InternalMediaClientTests.swift b/ios/Tests/GutenbergKitTests/Media/InternalMediaClientTests.swift
new file mode 100644
index 000000000..abf88ca67
--- /dev/null
+++ b/ios/Tests/GutenbergKitTests/Media/InternalMediaClientTests.swift
@@ -0,0 +1,336 @@
+import Foundation
+import Testing
+
+@testable import GutenbergKit
+
+// MARK: - Streaming Multipart Body Tests
+
+@Suite("InternalMediaClient streaming multipart body")
+struct MultipartBodyStreamTests {
+
+ @Test("streaming output matches in-memory multipart format")
+ func streamMatchesInMemory() throws {
+ let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("stream-test-\(UUID().uuidString)")
+ let fileContent = Data("hello world".utf8)
+ try fileContent.write(to: tempFile)
+ defer { try? FileManager.default.removeItem(at: tempFile) }
+
+ let boundary = "test-boundary-123"
+ let filename = "photo.jpg"
+ let mimeType = "image/jpeg"
+
+ // Build expected output using the old in-memory approach.
+ var expected = Data()
+ expected.append(Data("--\(boundary)\r\n".utf8))
+ expected.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".utf8))
+ expected.append(Data("Content-Type: \(mimeType)\r\n\r\n".utf8))
+ expected.append(fileContent)
+ expected.append(Data("\r\n--\(boundary)--\r\n".utf8))
+
+ // Build streaming output.
+ let (stream, contentLength) = try InternalMediaClient.multipartBodyStream(
+ fileURL: tempFile, boundary: boundary, filename: filename, mimeType: mimeType, extraFields: []
+ )
+ #expect(contentLength == expected.count)
+
+ let result = readAllFromStream(stream)
+ #expect(result == expected)
+ }
+
+ @Test("escapes CR/LF and quotes so a crafted filename can't inject headers or parts")
+ func escapesHeaderInjection() throws {
+ let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("stream-test-\(UUID().uuidString)")
+ try Data("file-bytes".utf8).write(to: tempFile)
+ defer { try? FileManager.default.removeItem(at: tempFile) }
+
+ // Craft a filename, field name, and MIME type that each try to smuggle a CRLF
+ // and a fake header into the body relayed to WordPress.
+ let (stream, _) = try InternalMediaClient.multipartBodyStream(
+ fileURL: tempFile,
+ boundary: "boundary",
+ filename: "evil\"\r\nX-Injected-File: 1.jpg",
+ mimeType: "image/jpeg\r\nX-Injected-Type: 1",
+ extraFields: [("field\"\r\nX-Injected-Name: 1", Data("v".utf8))]
+ )
+ let text = String(decoding: readAllFromStream(stream), as: UTF8.self)
+
+ // None of the crafted CRLF sequences may survive as a real header break.
+ #expect(!text.contains("\r\nX-Injected-File:"))
+ #expect(!text.contains("\r\nX-Injected-Type:"))
+ #expect(!text.contains("\r\nX-Injected-Name:"))
+ }
+
+ @Test("includes non-file parts (e.g. post) ahead of the file")
+ func multipartBodyIncludesExtraParts() throws {
+ let boundary = "boundary"
+ let filename = "photo.jpg"
+ let mimeType = "image/jpeg"
+ let fileContent = Data("image bytes".utf8)
+ let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("stream-extra-\(UUID().uuidString)")
+ try fileContent.write(to: tempFile)
+ defer { try? FileManager.default.removeItem(at: tempFile) }
+
+ var expected = Data()
+ expected.append(Data("--\(boundary)\r\n".utf8))
+ expected.append(Data("Content-Disposition: form-data; name=\"post\"\r\n\r\n".utf8))
+ expected.append(Data("123\r\n".utf8))
+ expected.append(Data("--\(boundary)\r\n".utf8))
+ expected.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".utf8))
+ expected.append(Data("Content-Type: \(mimeType)\r\n\r\n".utf8))
+ expected.append(fileContent)
+ expected.append(Data("\r\n--\(boundary)--\r\n".utf8))
+
+ let (stream, contentLength) = try InternalMediaClient.multipartBodyStream(
+ fileURL: tempFile, boundary: boundary, filename: filename, mimeType: mimeType,
+ extraFields: [("post", Data("123".utf8))]
+ )
+ #expect(contentLength == expected.count)
+ #expect(readAllFromStream(stream) == expected)
+ }
+
+ @Test("forwards a non-UTF-8 field value verbatim")
+ func multipartBodyPreservesNonUTF8FieldValue() throws {
+ let boundary = "boundary"
+ let filename = "photo.jpg"
+ let mimeType = "image/jpeg"
+ let fileContent = Data("image bytes".utf8)
+ let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("stream-binary-\(UUID().uuidString)")
+ try fileContent.write(to: tempFile)
+ defer { try? FileManager.default.removeItem(at: tempFile) }
+
+ // A field value that is not valid UTF-8 (a lone 0xFF byte between ASCII bytes).
+ let binaryValue = Data([0x61, 0xFF, 0x62])
+
+ var expected = Data()
+ expected.append(Data("--\(boundary)\r\n".utf8))
+ expected.append(Data("Content-Disposition: form-data; name=\"blob\"\r\n\r\n".utf8))
+ expected.append(binaryValue)
+ expected.append(Data("\r\n".utf8))
+ expected.append(Data("--\(boundary)\r\n".utf8))
+ expected.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".utf8))
+ expected.append(Data("Content-Type: \(mimeType)\r\n\r\n".utf8))
+ expected.append(fileContent)
+ expected.append(Data("\r\n--\(boundary)--\r\n".utf8))
+
+ let (stream, contentLength) = try InternalMediaClient.multipartBodyStream(
+ fileURL: tempFile, boundary: boundary, filename: filename, mimeType: mimeType,
+ extraFields: [("blob", binaryValue)]
+ )
+ #expect(contentLength == expected.count)
+ // The raw 0xFF byte survives — it was not coerced through String.
+ #expect(readAllFromStream(stream) == expected)
+ }
+
+ @Test("content length matches actual stream output for larger files")
+ func contentLengthAccurate() throws {
+ let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("stream-test-\(UUID().uuidString)")
+ let fileContent = Data(repeating: 0x42, count: 100_000)
+ try fileContent.write(to: tempFile)
+ defer { try? FileManager.default.removeItem(at: tempFile) }
+
+ let (stream, contentLength) = try InternalMediaClient.multipartBodyStream(
+ fileURL: tempFile, boundary: "boundary", filename: "big.bin", mimeType: "application/octet-stream", extraFields: []
+ )
+
+ let result = readAllFromStream(stream)
+ #expect(result.count == contentLength)
+ }
+
+ @Test("writeMultipartBody streams the full body and closing boundary when the file reads cleanly")
+ func writeMultipartBodyWritesFullBody() throws {
+ let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("wmb-\(UUID().uuidString)")
+ let fileContent = Data("the file bytes".utf8)
+ try fileContent.write(to: tempFile)
+ defer { try? FileManager.default.removeItem(at: tempFile) }
+
+ let fileHandle = try FileHandle(forReadingFrom: tempFile)
+ defer { try? fileHandle.close() }
+
+ let output = OutputStream.toMemory()
+ output.open()
+ defer { output.close() }
+
+ let preamble = Data("PREAMBLE".utf8)
+ let epilogue = Data("EPILOGUE".utf8)
+ let ok = InternalMediaClient.writeMultipartBody(
+ fileHandle: fileHandle, fileSize: fileContent.count,
+ preamble: preamble, epilogue: epilogue, to: output
+ )
+
+ #expect(ok)
+ let written = output.property(forKey: .dataWrittenToMemoryStreamKey) as? Data
+ #expect(written == preamble + fileContent + epilogue)
+ }
+
+ @Test("writeMultipartBody aborts without the closing boundary when the file is shorter than measured")
+ func writeMultipartBodyAbortsOnShortFile() throws {
+ let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("wmb-short-\(UUID().uuidString)")
+ let fileContent = Data("only ten!!".utf8) // 10 bytes
+ try fileContent.write(to: tempFile)
+ defer { try? FileManager.default.removeItem(at: tempFile) }
+
+ let fileHandle = try FileHandle(forReadingFrom: tempFile)
+ defer { try? fileHandle.close() }
+
+ let output = OutputStream.toMemory()
+ output.open()
+ defer { output.close() }
+
+ let preamble = Data("PREAMBLE".utf8)
+ let epilogue = Data("EPILOGUE".utf8)
+ // Claim the file is larger than it is, as if it shrank after being measured.
+ let ok = InternalMediaClient.writeMultipartBody(
+ fileHandle: fileHandle, fileSize: fileContent.count + 100,
+ preamble: preamble, epilogue: epilogue, to: output
+ )
+
+ #expect(!ok)
+ // The preamble and the real file bytes were written, but NOT the closing
+ // boundary — a short body must not masquerade as a complete multipart.
+ let written = (output.property(forKey: .dataWrittenToMemoryStreamKey) as? Data) ?? Data()
+ #expect(written == preamble + fileContent)
+ }
+}
+
+// MARK: - InternalMediaClient Relay Tests
+
+@Suite("InternalMediaClient relay")
+struct InternalMediaClientRelayTests {
+
+ @Test("relays a non-2xx WordPress response instead of throwing")
+ func relaysErrorResponseVerbatim() async throws {
+ // A WordPress REST error body, returned with a non-2xx status.
+ let errorBody = Data(#"{"code":"rest_cannot_create","message":"Sorry, you are not allowed to upload this file type."}"#.utf8)
+ let client = RelayStubHTTPClient(statusCode: 403, body: errorBody)
+ let uploader = InternalMediaClient(httpClient: client, siteApiRoot: URL(string: "https://example.com/wp-json/")!)
+
+ let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("relay-\(UUID().uuidString).jpg")
+ try Data("fake image".utf8).write(to: tempFile)
+ defer { try? FileManager.default.removeItem(at: tempFile) }
+
+ // performUpload must route through performRaw, which does NOT validate status,
+ // so WordPress's 403 + body flow through verbatim. A revert to perform() would
+ // throw on the non-2xx (RelayStubHTTPClient.perform mirrors that), failing here.
+ let response = try await uploader.upload(
+ fileURL: tempFile, mimeType: "image/jpeg", filename: "photo.jpg", fields: [], query: ""
+ )
+
+ #expect(response.statusCode == 403)
+ #expect(response.body == errorBody)
+ }
+
+ @Test("relays the upload attachment ID header from a failed upload")
+ func relaysUploadAttachmentIdHeader() async throws {
+ // WordPress sets this header on an upload whose attachment row was created
+ // before metadata generation fataled. The editor reads it to retry
+ // post-process and clean up the orphan, so it must survive the relay.
+ let client = RelayStubHTTPClient(
+ statusCode: 500,
+ body: Data(#"{"code":"rest_upload_error"}"#.utf8),
+ headerFields: ["x-wp-upload-attachment-id": "4242"]
+ )
+ let uploader = InternalMediaClient(
+ httpClient: client, siteApiRoot: URL(string: "https://example.com/wp-json/")!)
+
+ let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent(
+ "header-\(UUID().uuidString).jpg")
+ try Data("fake image".utf8).write(to: tempFile)
+ defer { try? FileManager.default.removeItem(at: tempFile) }
+
+ let response = try await uploader.upload(
+ fileURL: tempFile, mimeType: "image/jpeg", filename: "photo.jpg", fields: [], query: ""
+ )
+
+ #expect(response.statusCode == 500)
+ #expect(response.headers["x-wp-upload-attachment-id"] == "4242")
+ }
+
+ @Test("omits unrelated upstream headers from the relay")
+ func omitsUnrelatedHeaders() async throws {
+ let client = RelayStubHTTPClient(
+ statusCode: 201,
+ body: Data("{}".utf8),
+ headerFields: ["X-Powered-By": "PHP/8.2", "Set-Cookie": "session=secret"]
+ )
+ let uploader = InternalMediaClient(
+ httpClient: client, siteApiRoot: URL(string: "https://example.com/wp-json/")!)
+
+ let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent(
+ "unrelated-\(UUID().uuidString).jpg")
+ try Data("fake image".utf8).write(to: tempFile)
+ defer { try? FileManager.default.removeItem(at: tempFile) }
+
+ let response = try await uploader.upload(
+ fileURL: tempFile, mimeType: "image/jpeg", filename: "photo.jpg", fields: [], query: ""
+ )
+
+ #expect(response.headers.isEmpty)
+ }
+
+ @Test("deletes an attachment, carrying the namespace and force query")
+ func deletesAttachment() async throws {
+ let client = URLCapturingHTTPClient()
+ let uploader = InternalMediaClient(
+ httpClient: client,
+ siteApiRoot: URL(string: "https://example.com/wp-json")!,
+ siteApiNamespace: ["sites/123"]
+ )
+
+ _ = try await uploader.deleteMedia(attachmentId: "42", query: "?force=true")
+
+ let url = try #require(client.lastURL)
+ #expect(
+ url.absoluteString == "https://example.com/wp-json/wp/v2/sites/123/media/42?force=true")
+ }
+
+ @Test("carries the namespace and request query through to the media endpoint")
+ func forwardsNamespaceAndQuery() async throws {
+ let client = URLCapturingHTTPClient()
+ let uploader = InternalMediaClient(
+ httpClient: client,
+ siteApiRoot: URL(string: "https://example.com/wp-json")!,
+ siteApiNamespace: ["sites/123"]
+ )
+ let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("query-\(UUID().uuidString).jpg")
+ try Data("img".utf8).write(to: tempFile)
+ defer { try? FileManager.default.removeItem(at: tempFile) }
+
+ _ = try await uploader.upload(
+ fileURL: tempFile, mimeType: "image/jpeg", filename: "photo.jpg",
+ fields: [], query: "?_embed=wp:featuredmedia"
+ )
+
+ // Namespace inserted via the shared builder, and the query preserved verbatim —
+ // including the `:`, which would make URL(string:) return nil and drop it (#6).
+ let url = try #require(client.lastURL)
+ #expect(url.absoluteString == "https://example.com/wp-json/wp/v2/sites/123/media?_embed=wp:featuredmedia")
+ }
+
+ @Test("sends the editor's fields ahead of the file, in order, with repeated names intact")
+ func sendsFieldsInOrder() async throws {
+ let client = BodyCapturingHTTPClient()
+ let uploader = InternalMediaClient(httpClient: client, siteApiRoot: URL(string: "https://example.com/wp-json/")!)
+ let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("fields-\(UUID().uuidString).jpg")
+ try Data("img".utf8).write(to: tempFile)
+ defer { try? FileManager.default.removeItem(at: tempFile) }
+
+ _ = try await uploader.upload(
+ fileURL: tempFile, mimeType: "image/jpeg", filename: "photo.jpg",
+ fields: [
+ MediaUploadField(name: "post", value: "7"),
+ MediaUploadField(name: "field[]", value: "a"),
+ MediaUploadField(name: "field[]", value: "日本語"),
+ ],
+ query: ""
+ )
+
+ let body = String(decoding: try #require(client.lastBody), as: UTF8.self)
+ let post = try #require(body.range(of: "name=\"post\"\r\n\r\n7\r\n"))
+ let first = try #require(body.range(of: "name=\"field[]\"\r\n\r\na\r\n"))
+ let second = try #require(body.range(of: "name=\"field[]\"\r\n\r\n日本語\r\n"))
+ let file = try #require(body.range(of: "name=\"file\"; filename=\"photo.jpg\""))
+ #expect(post.lowerBound < first.lowerBound)
+ #expect(first.lowerBound < second.lowerBound)
+ #expect(second.lowerBound < file.lowerBound)
+ }
+}
diff --git a/ios/Tests/GutenbergKitTests/Media/MediaFileSchemeHandlerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaFileSchemeHandlerTests.swift
new file mode 100644
index 000000000..4c8c3d120
--- /dev/null
+++ b/ios/Tests/GutenbergKitTests/Media/MediaFileSchemeHandlerTests.swift
@@ -0,0 +1,99 @@
+import Foundation
+import Testing
+import WebKit
+
+@testable import GutenbergKit
+
+@MainActor
+@Suite("MediaFileSchemeHandler")
+struct MediaFileSchemeHandlerTests {
+ private let root = FileManager.default.temporaryDirectory.appending(component: "media-root-\(UUID().uuidString)")
+
+ private func writeUpload(_ contents: Data, named name: String) throws {
+ let uploads = root.appending(component: "Uploads")
+ try FileManager.default.createDirectory(at: uploads, withIntermediateDirectories: true)
+ try contents.write(to: uploads.appending(component: name))
+ }
+
+ private func request(_ path: String) -> FakeSchemeTask {
+ FakeSchemeTask(request: URLRequest(url: URL(string: "gbk-media-file://\(path)")!))
+ }
+
+ @Test("streams a file larger than one chunk, intact, with its type and length")
+ func streamsFiles() async throws {
+ let contents = Data((0..<(MediaFileSchemeHandler.chunkSize * 2 + 17)).map { UInt8($0 % 251) })
+ try writeUpload(contents, named: "clip.mp4")
+ let handler = MediaFileSchemeHandler(rootURL: root)
+ let task = request("/Uploads/clip.mp4")
+
+ handler.start(task)
+ await task.waitUntilAnswered()
+
+ #expect(task.status == 200)
+ #expect(task.header("Content-Type") == "video/mp4")
+ #expect(task.header("Content-Length") == "\(contents.count)")
+ #expect(task.header("Access-Control-Allow-Origin") == "*")
+ #expect(task.body.count == contents.count)
+ #expect(task.body.hasSameBytes(as: contents))
+ #expect(handler.activeRequestCount == 0)
+ }
+
+ @Test("serves a filename that needs percent-encoding")
+ func servesEncodedNames() async throws {
+ try writeUpload(Data("hi".utf8), named: "IMG 0001.HEIC")
+ let handler = MediaFileSchemeHandler(rootURL: root)
+ let task = request("/Uploads/IMG%200001.HEIC")
+
+ handler.start(task)
+ await task.waitUntilAnswered()
+
+ #expect(task.body == Data("hi".utf8))
+ }
+
+ @Test("refuses a path that would escape the media directory")
+ func refusesTraversal() async throws {
+ let handler = MediaFileSchemeHandler(rootURL: root)
+ let task = request("/Uploads/../../../../etc/hosts")
+
+ handler.start(task)
+ await task.waitUntilAnswered()
+
+ #expect(task.response == nil)
+ #expect((task.failure as? URLError)?.code == .badURL)
+ }
+
+ @Test("fails a request for a file that doesn't exist")
+ func missingFiles() async throws {
+ let handler = MediaFileSchemeHandler(rootURL: root)
+ let task = request("/Uploads/missing.jpg")
+
+ handler.start(task)
+ await task.waitUntilAnswered()
+
+ #expect(task.failure != nil)
+ #expect(handler.activeRequestCount == 0)
+ }
+
+ @Test("stops delivering to a request WebKit stopped")
+ func stopsDelivering() async throws {
+ let contents = Data(count: MediaFileSchemeHandler.chunkSize * 8)
+ try writeUpload(contents, named: "big.bin")
+ let handler = MediaFileSchemeHandler(rootURL: root)
+ let task = request("/Uploads/big.bin")
+
+ handler.start(task)
+ handler.stop(task)
+ try await Task.sleep(for: .milliseconds(100))
+
+ #expect(!task.finished, "finished a stopped task, which WebKit raises on")
+ #expect(task.body.count < contents.count)
+ #expect(handler.activeRequestCount == 0)
+ }
+
+ @Test("resolves only paths inside the root")
+ func resolvesPaths() {
+ #expect(MediaFileManager.fileURL(for: URL(string: "gbk-media-file:///Uploads/a.jpg")!, root: root)?.lastPathComponent == "a.jpg")
+ #expect(MediaFileManager.fileURL(for: URL(string: "gbk-media-file:///../a.jpg")!, root: root) == nil)
+ #expect(MediaFileManager.fileURL(for: URL(string: "gbk-media-file:///Uploads/%2E%2E/%2E%2E/a.jpg")!, root: root) == nil)
+ }
+}
diff --git a/ios/Tests/GutenbergKitTests/Media/MediaImportTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaImportTests.swift
new file mode 100644
index 000000000..2efad1c29
--- /dev/null
+++ b/ios/Tests/GutenbergKitTests/Media/MediaImportTests.swift
@@ -0,0 +1,75 @@
+import Foundation
+import Testing
+
+@testable import GutenbergKit
+
+@Suite("Native media import")
+struct MediaImportTests {
+ private let root = FileManager.default.temporaryDirectory.appending(component: "import-root-\(UUID().uuidString)")
+
+ @Test("imports a file under its own name, in its own directory, as a clone")
+ func importsAsAClone() async throws {
+ let source = try makeTemporaryFile(Data(repeating: 7, count: 256 * 1024), named: "IMG 0001.MOV")
+ let manager = MediaFileManager(rootURL: root)
+
+ let info = try await manager.importFile(at: source)
+
+ let url = try #require(info.url.flatMap(URL.init(string:)))
+ #expect(url.scheme == MediaFileSchemeHandler.scheme)
+ #expect(info.type == "video/quicktime")
+ let fileURL = try #require(MediaFileManager.fileURL(for: url, root: root))
+ #expect(fileURL.lastPathComponent == "IMG 0001.MOV")
+ #expect(fileURL.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent == "Uploads")
+ #expect(try Data(contentsOf: fileURL).hasSameBytes(as: Data(contentsOf: source)))
+ #expect(try sharesStorage(fileURL, with: source), "the import copied the bytes instead of cloning them")
+ }
+
+ @Test("removes WebKit's old upload copies, and nothing else")
+ func removesStaleWebKitUploadCopies() throws {
+ let tmp = URL.randomTemporaryDirectory
+ let old = tmp.appending(component: "WKFileUploadPanel-old")
+ let recent = tmp.appending(component: "WKFileUploadPanel-recent")
+ let unrelated = tmp.appending(component: "GutenbergKit-uploads")
+ for directory in [old, recent, unrelated] {
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ try Data("video".utf8).write(to: directory.appending(component: "clip.mp4"))
+ }
+ let threeDaysAgo = Date.now.addingTimeInterval(-3 * 24 * 60 * 60)
+ try FileManager.default.setAttributes([.creationDate: threeDaysAgo], ofItemAtPath: old.path)
+ try FileManager.default.setAttributes([.creationDate: threeDaysAgo], ofItemAtPath: unrelated.path)
+
+ MediaFileManager.removeStaleWebKitUploadCopies(in: tmp)
+
+ #expect(!FileManager.default.fileExists(atPath: old.path))
+ #expect(FileManager.default.fileExists(atPath: recent.path))
+ #expect(FileManager.default.fileExists(atPath: unrelated.path))
+ }
+
+ @Test("maps a file back to the gbk-media-file URL that names it")
+ func mediaURLRoundTrips() throws {
+ let file = root.appending(component: "Uploads/abc/IMG 0001.HEIC")
+ let url = try #require(MediaFileManager.mediaURL(forFile: file, root: root))
+
+ #expect(url.absoluteString == "gbk-media-file:///Uploads/abc/IMG%200001.HEIC")
+ #expect(MediaFileManager.fileURL(for: url, root: root) == file.standardizedFileURL)
+ #expect(MediaFileManager.mediaURL(forFile: URL(fileURLWithPath: "/etc/hosts"), root: root) == nil)
+ }
+}
+
+/// Whether two files share their storage — an APFS clone — read with `getattrlist`.
+func sharesStorage(_ a: URL, with b: URL) throws -> Bool {
+ func cloneAttributes(_ url: URL) throws -> (cloneID: UInt64, privateSize: Int64) {
+ var list = attrlist()
+ list.bitmapcount = u_short(ATTR_BIT_MAP_COUNT)
+ list.forkattr = attrgroup_t(ATTR_CMNEXT_PRIVATESIZE) | attrgroup_t(ATTR_CMNEXT_CLONEID)
+ var buffer = [UInt8](repeating: 0, count: 64)
+ let result = url.withUnsafeFileSystemRepresentation { getattrlist($0!, &list, &buffer, buffer.count, UInt32(FSOPT_ATTR_CMN_EXTENDED)) }
+ guard result == 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) }
+ return buffer.withUnsafeBytes {
+ (cloneID: $0.loadUnaligned(fromByteOffset: 12, as: UInt64.self), privateSize: $0.loadUnaligned(fromByteOffset: 4, as: Int64.self))
+ }
+ }
+ let first = try cloneAttributes(a)
+ let second = try cloneAttributes(b)
+ return first.cloneID == second.cloneID && first.privateSize == 0 && second.privateSize == 0
+}
diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadSchemeHandlerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadSchemeHandlerTests.swift
new file mode 100644
index 000000000..b0762db4a
--- /dev/null
+++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadSchemeHandlerTests.swift
@@ -0,0 +1,319 @@
+import Foundation
+import Testing
+import WebKit
+
+@testable import GutenbergKit
+
+@MainActor
+@Suite("MediaUploadSchemeHandler")
+struct MediaUploadSchemeHandlerTests {
+ private func makeHandler(service: (any MediaUploading)?) -> MediaUploadSchemeHandler {
+ MediaUploadSchemeHandler(
+ service: service,
+ store: MediaUploadSessionStore(
+ directory: FileManager.default.temporaryDirectory.appending(component: "scheme-tests-\(UUID().uuidString)")
+ )
+ )
+ }
+
+ /// Starts a request and waits for the handler to answer it.
+ @discardableResult
+ private func send(
+ _ handler: MediaUploadSchemeHandler,
+ _ path: String,
+ method: String = "POST",
+ json: [String: Any]? = nil,
+ body: Data? = nil
+ ) async throws -> FakeSchemeTask {
+ var request = URLRequest(url: URL(string: "gbk-upload://upload\(path)")!)
+ request.httpMethod = method
+ if let json {
+ request.httpBody = try JSONSerialization.data(withJSONObject: json)
+ } else {
+ request.httpBody = body
+ }
+ let task = FakeSchemeTask(request: request)
+ handler.start(task)
+ await task.waitUntilAnswered()
+ return task
+ }
+
+ private func beginSession(_ handler: MediaUploadSchemeHandler, size: Int) async throws -> String {
+ let task = try await send(handler, "/sessions", json: ["filename": "clip.mp4", "mimeType": "video/mp4", "size": size])
+ #expect(task.status == 201)
+ return try #require(task.json["id"] as? String)
+ }
+
+ // MARK: - Uploads from the page
+
+ @Test("assembles chunks, finishes with the fields and query, and relays WordPress's answer")
+ func uploadsInChunks() async throws {
+ let service = ScriptedUploadService { _ in
+ MediaUploadResponse(statusCode: 201, body: Data(#"{"id":42}"#.utf8))
+ }
+ let handler = makeHandler(service: service)
+
+ let id = try await beginSession(handler, size: 10)
+ let first = try await send(handler, "/sessions/\(id)/chunks?offset=0", body: Data("01234".utf8))
+ let second = try await send(handler, "/sessions/\(id)/chunks?offset=5", body: Data("56789".utf8))
+ let finish = try await send(handler, "/sessions/\(id)/finish", json: [
+ "fields": [["name": "post", "value": "7"]],
+ "query": "?_embed"
+ ])
+
+ #expect(first.json["received"] as? Int == 5)
+ #expect(second.json["received"] as? Int == 10)
+ #expect(finish.status == 201)
+ #expect(finish.json["id"] as? Int == 42)
+ #expect(finish.header("Content-Type") == "application/json")
+ let call = try #require(service.calls.first)
+ #expect(call.contents == Data("0123456789".utf8))
+ #expect(call.file.filename == "clip.mp4")
+ #expect(call.fields == [MediaUploadField(name: "post", value: "7")])
+ #expect(call.query == "?_embed")
+ #expect(!FileManager.default.fileExists(atPath: call.file.url.path), "the staging copy outlived the upload")
+ }
+
+ @Test("relays a failed upload's status and attachment ID, and exposes the header to the page")
+ func relaysRecoverableFailures() async throws {
+ let service = ScriptedUploadService { _ in
+ MediaUploadResponse(
+ statusCode: 500,
+ body: Data(#"{"code":"rest_upload_sideload_error"}"#.utf8),
+ headers: ["x-wp-upload-attachment-id": "42"]
+ )
+ }
+ let handler = makeHandler(service: service)
+ let id = try await beginSession(handler, size: 0)
+
+ let finish = try await send(handler, "/sessions/\(id)/finish", json: [:])
+
+ #expect(finish.status == 500)
+ #expect(finish.header("x-wp-upload-attachment-id") == "42")
+ #expect(finish.header("Access-Control-Expose-Headers") == "x-wp-upload-attachment-id")
+ #expect(finish.header("Access-Control-Allow-Origin") == "*")
+ }
+
+ @Test("answers every request with CORS headers, including errors")
+ func corsOnErrors() async throws {
+ let handler = makeHandler(service: ScriptedUploadService())
+
+ let notFound = try await send(handler, "/nope")
+ let preflight = try await send(handler, "/sessions", method: "OPTIONS")
+
+ #expect(notFound.status == 404)
+ #expect(notFound.header("Access-Control-Allow-Origin") == "*")
+ #expect(preflight.status == 204)
+ #expect(preflight.header("Access-Control-Allow-Methods")?.contains("POST") == true)
+ }
+
+ @Test("refuses a chunk with no body rather than leaving the file short")
+ func refusesEmptyChunks() async throws {
+ let handler = makeHandler(service: ScriptedUploadService())
+ let id = try await beginSession(handler, size: 4)
+
+ let chunk = try await send(handler, "/sessions/\(id)/chunks?offset=0", body: nil)
+
+ #expect(chunk.status == 400)
+ #expect(chunk.json["code"] as? String == "native_upload_empty_chunk")
+ }
+
+ @Test("refuses an out-of-order chunk")
+ func refusesOutOfOrderChunks() async throws {
+ let handler = makeHandler(service: ScriptedUploadService())
+ let id = try await beginSession(handler, size: 10)
+
+ let chunk = try await send(handler, "/sessions/\(id)/chunks?offset=5", body: Data("56789".utf8))
+
+ #expect(chunk.status == 409)
+ }
+
+ @Test("refuses to finish a file missing bytes, without uploading it")
+ func refusesIncompleteUploads() async throws {
+ let service = ScriptedUploadService()
+ let handler = makeHandler(service: service)
+ let id = try await beginSession(handler, size: 10)
+ try await send(handler, "/sessions/\(id)/chunks?offset=0", body: Data("01234".utf8))
+
+ let finish = try await send(handler, "/sessions/\(id)/finish", json: [:])
+
+ #expect(finish.status == 409)
+ #expect(service.calls.isEmpty)
+ }
+
+ @Test("answers an unknown session with a 404")
+ func unknownSession() async throws {
+ let handler = makeHandler(service: ScriptedUploadService())
+
+ let finish = try await send(handler, "/sessions/0f8fad5b-d9cb-469f-a165-70867728950e/finish", json: [:])
+
+ #expect(finish.status == 404)
+ }
+
+ @Test("a cancelled session can't be finished")
+ func cancelledSessions() async throws {
+ let service = ScriptedUploadService()
+ let handler = makeHandler(service: service)
+ let id = try await beginSession(handler, size: 0)
+
+ let cancel = try await send(handler, "/sessions/\(id)/cancel")
+ let finish = try await send(handler, "/sessions/\(id)/finish", json: [:])
+
+ #expect(cancel.status == 204)
+ #expect(finish.status == 404)
+ #expect(service.calls.isEmpty)
+ }
+
+ @Test("answers a malformed request body with a 400")
+ func malformedBodies() async throws {
+ let handler = makeHandler(service: ScriptedUploadService())
+
+ let begin = try await send(handler, "/sessions", body: Data("not json".utf8))
+
+ #expect(begin.status == 400)
+ }
+
+ // MARK: - Deletes
+
+ @Test("relays a media delete with its query")
+ func relaysDeletes() async throws {
+ let service = ScriptedUploadService()
+ let handler = makeHandler(service: service)
+
+ let delete = try await send(handler, "/media/42/delete", json: ["query": "?force=true"])
+
+ #expect(delete.status == 200)
+ #expect(service.deletes.first?.0 == "42")
+ #expect(service.deletes.first?.1 == "?force=true")
+ }
+
+ @Test("refuses a delete for an attachment ID that isn't numeric")
+ func refusesNonNumericDeletes() async throws {
+ let service = ScriptedUploadService()
+ let handler = makeHandler(service: service)
+
+ let delete = try await send(handler, "/media/../delete", json: [:])
+
+ #expect(delete.status == 404)
+ #expect(service.deletes.isEmpty)
+ }
+
+ // MARK: - Stopping
+
+ @Test("answers every request with a 503 when there is no media handling")
+ func unavailableWithoutAService() async throws {
+ let handler = makeHandler(service: nil)
+
+ let begin = try await send(handler, "/sessions", json: ["filename": "a.jpg", "mimeType": "image/jpeg"])
+
+ #expect(begin.status == 503)
+ #expect(begin.json["code"] as? String == "native_upload_unavailable")
+ }
+
+ @Test("disable() cancels an upload in flight and refuses new ones")
+ func disableCancelsUploads() async throws {
+ let gate = AsyncGate()
+ let service = ScriptedUploadService { _ in
+ await gate.enter()
+ try Task.checkCancellation()
+ return MediaUploadResponse(statusCode: 201, body: Data("{}".utf8))
+ }
+ let handler = makeHandler(service: service)
+ let id = try await beginSession(handler, size: 0)
+
+ var request = URLRequest(url: URL(string: "gbk-upload://upload/sessions/\(id)/finish")!)
+ request.httpMethod = "POST"
+ request.httpBody = Data("{}".utf8)
+ let finish = FakeSchemeTask(request: request)
+ handler.start(finish)
+ await gate.waitUntilEntered()
+
+ handler.disable()
+ await gate.open()
+ await finish.waitUntilAnswered()
+
+ #expect(finish.status == 503)
+ let begin = try await send(handler, "/sessions", json: ["filename": "a.jpg", "mimeType": "image/jpeg"])
+ #expect(begin.status == 503)
+ }
+
+ @Test("never answers a request WebKit stopped, and cancels its upload")
+ func stoppedRequestsAreNotAnswered() async throws {
+ let gate = AsyncGate()
+ let sawCancellation = Flag()
+ let service = ScriptedUploadService { _ in
+ await gate.enter()
+ if Task.isCancelled { sawCancellation.set() }
+ try Task.checkCancellation()
+ return MediaUploadResponse(statusCode: 201, body: Data("{}".utf8))
+ }
+ let handler = makeHandler(service: service)
+ let id = try await beginSession(handler, size: 0)
+
+ var request = URLRequest(url: URL(string: "gbk-upload://upload/sessions/\(id)/finish")!)
+ request.httpMethod = "POST"
+ request.httpBody = Data("{}".utf8)
+ let finish = FakeSchemeTask(request: request)
+ handler.start(finish)
+ await gate.waitUntilEntered()
+
+ handler.stop(finish)
+ await gate.open()
+ for _ in 0..<50 where handler.activeRequestCount > 0 || !sawCancellation.isSet {
+ try await Task.sleep(for: .milliseconds(10))
+ }
+ try await Task.sleep(for: .milliseconds(50))
+
+ #expect(sawCancellation.isSet, "the upload kept running for a page that left")
+ #expect(finish.response == nil, "answered a stopped task, which WebKit raises on")
+ #expect(!finish.finished)
+ }
+}
+
+/// A `WKURLSchemeTask` that records what the handler sends it.
+final class FakeSchemeTask: NSObject, WKURLSchemeTask, @unchecked Sendable {
+ let request: URLRequest
+ private let lock = NSLock()
+ private var _response: URLResponse?
+ private var _body = Data()
+ private var _finished = false
+ private var _failure: (any Error)?
+
+ var response: URLResponse? { lock.withLock { _response } }
+ var body: Data { lock.withLock { _body } }
+ var finished: Bool { lock.withLock { _finished } }
+ var failure: (any Error)? { lock.withLock { _failure } }
+
+ init(request: URLRequest) {
+ self.request = request
+ }
+
+ func didReceive(_ response: URLResponse) { lock.withLock { _response = response } }
+ func didReceive(_ data: Data) { lock.withLock { _body.append(data) } }
+ func didFinish() { lock.withLock { _finished = true } }
+ func didFailWithError(_ error: any Error) { lock.withLock { _failure = error } }
+
+ var status: Int? { (response as? HTTPURLResponse)?.statusCode }
+
+ func header(_ name: String) -> String? {
+ (response as? HTTPURLResponse)?.value(forHTTPHeaderField: name)
+ }
+
+ var json: [String: Any] {
+ (try? JSONSerialization.jsonObject(with: body) as? [String: Any]) ?? [:]
+ }
+
+ func waitUntilAnswered(timeout: Duration = patientTimeout) async {
+ let deadline = ContinuousClock.now + timeout
+ while !finished && failure == nil && ContinuousClock.now < deadline {
+ try? await Task.sleep(for: .milliseconds(2))
+ }
+ }
+}
+
+final class Flag: @unchecked Sendable {
+ private let lock = NSLock()
+ private var value = false
+ var isSet: Bool { lock.withLock { value } }
+ func set() { lock.withLock { value = true } }
+}
diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift
deleted file mode 100644
index e698fe890..000000000
--- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift
+++ /dev/null
@@ -1,1297 +0,0 @@
-import Foundation
-import GutenbergKitHTTP
-import Testing
-@testable import GutenbergKit
-
-/// Check if HTTPServer can bind in this environment (fails in some test sandboxes).
-private let _canStartUploadServer: Bool = {
- let result = UnsafeMutableSendablePointer(false)
- let semaphore = DispatchSemaphore(value: 0)
- Task {
- do {
- let server = try await MediaUploadServer.start()
- server.stop()
- result.value = true
- } catch {
- result.value = false
- }
- semaphore.signal()
- }
- semaphore.wait()
- return result.value
-}()
-
-/// Sendable wrapper for a mutable value, used to communicate results out of a Task.
-private final class UnsafeMutableSendablePointer: @unchecked Sendable {
- var value: T
- init(_ value: T) { self.value = value }
-}
-
-// MARK: - Integration Tests (require network)
-
-@Suite("MediaUploadServer Integration", .enabled(if: _canStartUploadServer))
-struct MediaUploadServerTests {
-
- @Test("starts and provides a port and token")
- func startAndStop() async throws {
- let server = try await MediaUploadServer.start()
- #expect(server.port > 0)
- #expect(!server.token.isEmpty)
- server.stop()
- }
-
- @Test("rejects requests without auth token")
- func rejectsUnauthenticated() async throws {
- let server = try await MediaUploadServer.start()
- defer { server.stop() }
-
- let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
-
- let (_, response) = try await URLSession.shared.data(for: request)
- let httpResponse = try #require(response as? HTTPURLResponse)
- #expect(httpResponse.statusCode == 407)
- }
-
- @Test("rejects requests with wrong token")
- func rejectsWrongToken() async throws {
- let server = try await MediaUploadServer.start()
- defer { server.stop() }
-
- let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("Bearer wrong-token", forHTTPHeaderField: "Relay-Authorization")
-
- let (_, response) = try await URLSession.shared.data(for: request)
- let httpResponse = try #require(response as? HTTPURLResponse)
- #expect(httpResponse.statusCode == 407)
- }
-
- @Test("responds to OPTIONS preflight with CORS headers")
- func corsPreflightResponse() async throws {
- let server = try await MediaUploadServer.start()
- defer { server.stop() }
-
- let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
- var request = URLRequest(url: url)
- request.httpMethod = "OPTIONS"
-
- let (_, response) = try await URLSession.shared.data(for: request)
- let httpResponse = try #require(response as? HTTPURLResponse)
- #expect(httpResponse.statusCode == 204)
- #expect(httpResponse.value(forHTTPHeaderField: "Access-Control-Allow-Origin") == "*")
- #expect(httpResponse.value(forHTTPHeaderField: "Access-Control-Allow-Methods")?.contains("POST") == true)
- }
-
- @Test("returns 404 for unknown paths")
- func unknownPath() async throws {
- let server = try await MediaUploadServer.start()
- defer { server.stop() }
-
- let url = URL(string: "http://127.0.0.1:\(server.port)/unknown")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
-
- let (_, response) = try await URLSession.shared.data(for: request)
- let httpResponse = try #require(response as? HTTPURLResponse)
- #expect(httpResponse.statusCode == 404)
- }
-
- @Test("routes /upload with a query string and relays the query")
- func uploadWithQueryString() async throws {
- let processor = ProcessOnlyProcessor()
- let mockUploader = MockInternalMediaClient()
- let server = try await MediaUploadServer.start(processor: processor, internalClient: mockUploader)
- defer { server.stop() }
-
- // `@wordpress/media-utils` uploads to `/wp/v2/media?_embed=wp:featuredmedia`,
- // so the middleware forwards that query on to the native server. Routing must
- // match on the path alone, and the query must reach WordPress unchanged.
- let boundary = UUID().uuidString
- let body = buildMultipartBody(boundary: boundary, filename: "photo.jpg", mimeType: "image/jpeg", data: Data("fake image data".utf8))
-
- let url = URL(string: "http://127.0.0.1:\(server.port)/upload?_embed=wp:featuredmedia")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
- request.httpBody = body
-
- let (_, response) = try await URLSession.shared.data(for: request)
- let httpResponse = try #require(response as? HTTPURLResponse)
- #expect(httpResponse.statusCode == 201)
- // The processor returns `.original`, so this is the passthrough branch.
- // Pin which branch ran — `lastQuery` is recorded by both, so without this
- // the query assertion would pass even if routing collapsed onto one path.
- #expect(mockUploader.passthroughUploadCalled)
- #expect(!mockUploader.uploadCalled)
- #expect(mockUploader.lastQuery == "?_embed=wp:featuredmedia")
- }
-
- @Test("relays the uploader's own Content-Type instead of emitting it twice")
- func relayedContentTypeWins() async throws {
- // `HTTPResponse` serializes every header it is given, so appending the JSON
- // default unconditionally would put `Content-Type` on the wire twice.
- // URLSession joins repeated headers with a comma, which is what a regression
- // looks like here.
- //
- // Exercised through the delete relay because every response `relayResponse`
- // handles — WordPress's own included — carries a `Content-Type`, so this is
- // the ordinary path rather than an edge case.
- let uploader = ContentTypeDeleteClient()
- let server = try await MediaUploadServer.start(internalClient: uploader)
- defer { server.stop() }
-
- let url = URL(string: "http://127.0.0.1:\(server.port)/media/42?force=true")!
- var request = URLRequest(url: url)
- request.httpMethod = "DELETE"
- request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
-
- let (_, response) = try await URLSession.shared.data(for: request)
- let httpResponse = try #require(response as? HTTPURLResponse)
-
- #expect(httpResponse.statusCode == 200)
- #expect(httpResponse.value(forHTTPHeaderField: "Content-Type") == "text/plain")
- }
-
- @Test("processes with the processor, then delivers and relays verbatim")
- func processesThenDelivers() async throws {
- let processor = ResizingProcessor()
- let internalClient = MockInternalMediaClient()
- let server = try await MediaUploadServer.start(processor: processor, internalClient: internalClient)
- defer { server.stop() }
-
- let boundary = UUID().uuidString
- let fileData = "fake image data".data(using: .utf8)!
- let body = buildMultipartBody(boundary: boundary, filename: "photo.jpg", mimeType: "image/jpeg", data: fileData)
-
- let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
- request.httpBody = body
-
- let (data, response) = try await URLSession.shared.data(for: request)
- let httpResponse = try #require(response as? HTTPURLResponse)
- #expect(httpResponse.statusCode == 201)
-
- // The processor only transforms; GutenbergKit performs the upload.
- #expect(internalClient.uploadCalled)
-
- // The server relays WordPress's raw response body verbatim.
- let object = try JSONSerialization.jsonObject(with: data)
- let json = try #require(object as? [String: Any])
- #expect(json["id"] as? Int == 99)
- #expect(json["source_url"] as? String == "https://example.com/doc.pdf")
- #expect(json["media_type"] as? String == "file")
- }
-
- /// Pins the one capability dropping `: AnyObject` exists to deliver: a value type can
- /// conform, and the server actually calls it.
- ///
- /// Every other conformer in the tree is a class, so without this nothing exercises the
- /// boxed-existential path — copied into `Handler`, captured by the `@Sendable`
- /// handler closure, read again at `processFile`. Re-imposing a class requirement, or
- /// breaking that path, would otherwise compile and pass green and surface only in a
- /// host's build.
- ///
- /// Asserts through the client's recorded metadata rather than state on the processor,
- /// because a `struct` witnessing a non-mutating requirement cannot record anything —
- /// which is the point.
- @Test("a value-type processor is admitted, called, and its result delivered")
- func valueTypeProcessorRuns() async throws {
- let internalClient = MockInternalMediaClient()
- let server = try await MediaUploadServer.start(
- processor: ValueTypeProcessor(), internalClient: internalClient
- )
- defer { server.stop() }
-
- let boundary = UUID().uuidString
- let body = buildMultipartBody(
- boundary: boundary, filename: "clip.mov", mimeType: "video/quicktime",
- data: Data("movie".utf8)
- )
- let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
- request.httpBody = body
-
- _ = try await URLSession.shared.data(for: request)
-
- // The transcoded metadata could only come from `processFile` having run.
- #expect(internalClient.uploadCalled)
- #expect(internalClient.lastUploadMimeType == "video/mp4")
- #expect(internalClient.lastUploadFilename == "clip.mp4")
- }
-
- @Test("uses passthrough when processor does not modify file")
- func processorPassthrough() async throws {
- let processor = ProcessOnlyProcessor()
- let mockUploader = MockInternalMediaClient()
- let server = try await MediaUploadServer.start(processor: processor, internalClient: mockUploader)
- defer { server.stop() }
-
- let boundary = UUID().uuidString
- let fileData = "fake data".data(using: .utf8)!
- let body = buildMultipartBody(boundary: boundary, filename: "doc.pdf", mimeType: "application/pdf", data: fileData)
-
- let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
- request.httpBody = body
-
- let (data, response) = try await URLSession.shared.data(for: request)
- let httpResponse = try #require(response as? HTTPURLResponse)
- #expect(httpResponse.statusCode == 201)
-
- #expect(processor.processFileCalled)
- // Passthrough: original body forwarded directly, not re-encoded.
- #expect(mockUploader.passthroughUploadCalled)
- #expect(!mockUploader.uploadCalled)
-
- // The server relays WordPress's raw response body verbatim.
- let object = try JSONSerialization.jsonObject(with: data)
- let json = try #require(object as? [String: Any])
- #expect(json["id"] as? Int == 99)
- }
-
- @Test("skips processing and the temp copy when the processor declines by metadata")
- func processorDeclinesByMetadata() async throws {
- let processor = DeclineByMetadataProcessor()
- let mockUploader = MockInternalMediaClient()
- let server = try await MediaUploadServer.start(processor: processor, internalClient: mockUploader)
- defer { server.stop() }
-
- let boundary = UUID().uuidString
- let body = buildMultipartBody(boundary: boundary, filename: "clip.mov", mimeType: "video/quicktime", data: Data("movie".utf8))
-
- let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
- request.httpBody = body
-
- let (_, response) = try await URLSession.shared.data(for: request)
- let httpResponse = try #require(response as? HTTPURLResponse)
- #expect(httpResponse.statusCode == 201)
-
- // Declined by metadata → the processor is never asked to process (so the file
- // was never materialized), and the upload is passed through directly.
- #expect(!processor.processFileCalled)
- #expect(mockUploader.passthroughUploadCalled)
- #expect(!mockUploader.uploadCalled)
- }
-
- @Test("forwards the processor's processed metadata to the uploader")
- func processedMetadataForwarded() async throws {
- let processor = ResizingProcessor()
- let mockUploader = MockInternalMediaClient()
- let server = try await MediaUploadServer.start(processor: processor, internalClient: mockUploader)
- defer { server.stop() }
-
- let boundary = UUID().uuidString
- let body = buildMultipartBody(boundary: boundary, filename: "clip.mov", mimeType: "video/quicktime", data: Data("movie".utf8))
-
- let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
- request.httpBody = body
-
- _ = try await URLSession.shared.data(for: request)
-
- // The processor changed the format, so the uploader must receive the new
- // metadata — not the original video/quicktime + clip.mov.
- #expect(mockUploader.uploadCalled)
- #expect(mockUploader.lastUploadMimeType == "video/mp4")
- #expect(mockUploader.lastUploadFilename == "clip.mp4")
- }
-
- @Test("deletes the processor's processed file after upload")
- func deletesProcessedFile() async throws {
- let processor = ResizingProcessor()
- let mockUploader = MockInternalMediaClient()
- let server = try await MediaUploadServer.start(processor: processor, internalClient: mockUploader)
- defer { server.stop() }
-
- let boundary = UUID().uuidString
- let body = buildMultipartBody(boundary: boundary, filename: "clip.mov", mimeType: "video/quicktime", data: Data("movie".utf8))
-
- let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
- request.httpBody = body
-
- _ = try await URLSession.shared.data(for: request)
-
- // The server owns the file the processor produced and must delete it once the
- // upload finishes — the defer in processAndUpload covers the success and throw
- // paths alike. A leaked processed file is a full-size temp per upload.
- let processedURL = try #require(processor.producedURL)
- #expect(!FileManager.default.fileExists(atPath: processedURL.path(percentEncoded: false)))
- }
-
- @Test("returns 413 with CORS headers when request body exceeds max size")
- func oversizedUploadReturns413WithCORSHeaders() async throws {
- let server = try await MediaUploadServer.start(maxRequestBodySize: 1024)
- defer { server.stop() }
-
- let boundary = UUID().uuidString
- let oversizedData = Data(repeating: 0x42, count: 2048)
- let body = buildMultipartBody(boundary: boundary, filename: "big.bin", mimeType: "application/octet-stream", data: oversizedData)
-
- let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
- request.httpBody = body
-
- let (data, response) = try await URLSession.shared.data(for: request)
- let httpResponse = try #require(response as? HTTPURLResponse)
- #expect(httpResponse.statusCode == 413)
- #expect(httpResponse.value(forHTTPHeaderField: "Access-Control-Allow-Origin") == "*")
-
- let responseBody = String(data: data, encoding: .utf8) ?? ""
- #expect(responseBody.contains("too large"))
- }
-
- @Test("unauthenticated oversized request returns 407, not 413 (auth precedes drain)")
- func oversizedUploadWithoutTokenReturns407() async throws {
- let server = try await MediaUploadServer.start(maxRequestBodySize: 1024)
- defer { server.stop() }
-
- let boundary = UUID().uuidString
- let oversizedData = Data(repeating: 0x42, count: 2048)
- let body = buildMultipartBody(boundary: boundary, filename: "big.bin", mimeType: "application/octet-stream", data: oversizedData)
-
- let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- // Deliberately no Relay-Authorization header.
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
- request.httpBody = body
-
- // Auth is checked on headers alone, before the oversized body is drained
- // or the handler runs — so the request is rejected with 407, not answered
- // with the handler's 413. An unauthenticated client must not be able to
- // make the server read (and discard) an arbitrarily large body.
- let (_, response) = try await URLSession.shared.data(for: request)
- let httpResponse = try #require(response as? HTTPURLResponse)
- #expect(httpResponse.statusCode == 407)
- }
-
- @Test("startup sweep deletes stale upload temps but preserves fresh ones")
- func cleanOrphanedUploadsAgeThreshold() async throws {
- let dir = FileManager.default.temporaryDirectory
- .appending(component: "GutenbergKit-uploads", directoryHint: .isDirectory)
- try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
-
- let stale = dir.appending(component: "stale-\(UUID().uuidString)")
- let fresh = dir.appending(component: "fresh-\(UUID().uuidString)")
- try Data("x".utf8).write(to: stale)
- try Data("y".utf8).write(to: fresh)
- defer {
- try? FileManager.default.removeItem(at: stale)
- try? FileManager.default.removeItem(at: fresh)
- }
- // Backdate the stale file well past the 1-hour cutoff.
- try FileManager.default.setAttributes(
- [.modificationDate: Date(timeIntervalSinceNow: -7200)],
- ofItemAtPath: stale.path(percentEncoded: false)
- )
-
- // start() kicks off cleanOrphanedUploads() off the editor-startup path.
- // The sweep must delete the aged file and keep the fresh one — a flipped
- // comparison would do the opposite and wipe an in-flight upload.
- let server = try await MediaUploadServer.start()
- await server.cleanupTask.value
- server.stop()
-
- #expect(!FileManager.default.fileExists(atPath: stale.path(percentEncoded: false)))
- #expect(FileManager.default.fileExists(atPath: fresh.path(percentEncoded: false)))
- }
-
- @Test("an uploader performs the upload and its result is relayed")
- func uploaderPerformsUpload() async throws {
- let uploader = RecordingUploader()
- let internalClient = MockInternalMediaClient()
- let server = try await MediaUploadServer.start(uploader: uploader, internalClient: internalClient)
- defer { server.stop() }
-
- let boundary = UUID().uuidString
- let body = buildMultipartBody(boundary: boundary, filename: "photo.jpg", mimeType: "image/jpeg", data: Data("fake image data".utf8))
- let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
- request.httpBody = body
-
- let (data, response) = try await URLSession.shared.data(for: request)
- let httpResponse = try #require(response as? HTTPURLResponse)
-
- #expect(httpResponse.statusCode == 201)
- #expect(String(decoding: data, as: UTF8.self).contains("\"id\":7"))
- // GutenbergKit stays out of the network when a host uploader is set.
- #expect(!internalClient.uploadCalled)
- #expect(!internalClient.passthroughUploadCalled)
- #expect(uploader.received?.filename == "photo.jpg")
- #expect(uploader.received?.mimeType == "image/jpeg")
- }
-
- @Test("an uploader receives the editor's form fields in order, and the query")
- func uploaderReceivesFieldsAndQuery() async throws {
- // Without `post` the attachment is created unattached, and repeated names (a
- // `field[]` array) must survive as repeats rather than collapse into a dictionary.
- let uploader = RecordingUploader()
- let server = try await MediaUploadServer.start(uploader: uploader, internalClient: MockInternalMediaClient())
- defer { server.stop() }
-
- let boundary = UUID().uuidString
- var body = Data()
- for (name, value) in [("post", "42"), ("tags[]", "a"), ("tags[]", "b")] {
- body.append("--\(boundary)\r\n")
- body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n")
- body.append("\(value)\r\n")
- }
- body.append("--\(boundary)\r\n")
- body.append("Content-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n")
- body.append("Content-Type: image/jpeg\r\n\r\n")
- body.append(Data("fake image data".utf8))
- body.append("\r\n--\(boundary)--\r\n")
-
- let url = URL(string: "http://127.0.0.1:\(server.port)/upload?_embed=wp:featuredmedia")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
- request.httpBody = body
-
- _ = try await URLSession.shared.data(for: request)
-
- let received = try #require(uploader.received)
- #expect(received.fields == [
- MediaUploadField(name: "post", value: "42"),
- MediaUploadField(name: "tags[]", value: "a"),
- MediaUploadField(name: "tags[]", value: "b"),
- ])
- #expect(received.query == "?_embed=wp:featuredmedia")
- }
-
- @Test("keeps a binary Blob part out of an uploader's fields")
- func binaryPartExcludedFromFields() async throws {
- // Pin rule 3: a Blob always has a filename, so it's dropped before the decode.
- let uploader = RecordingUploader()
- let server = try await MediaUploadServer.start(uploader: uploader, internalClient: MockInternalMediaClient())
- defer { server.stop() }
-
- let boundary = UUID().uuidString
- var body = Data()
- // Ordered as `uploadToServer` emits it: the file first, then additionalData.
- body.append("--\(boundary)\r\n")
- body.append("Content-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n")
- body.append("Content-Type: image/jpeg\r\n\r\n")
- body.append(Data("fake image data".utf8))
- body.append("\r\n--\(boundary)\r\n")
- body.append("Content-Disposition: form-data; name=\"post\"\r\n\r\n")
- body.append("42\r\n")
- // A Blob-shaped part: it has a filename, and its bytes are not valid UTF-8.
- body.append("--\(boundary)\r\n")
- body.append("Content-Disposition: form-data; name=\"blob\"; filename=\"blob\"\r\n")
- body.append("Content-Type: application/octet-stream\r\n\r\n")
- body.append(Data([0xED, 0xA0, 0x80]))
- body.append("\r\n--\(boundary)--\r\n")
-
- let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
- request.httpBody = body
-
- _ = try await URLSession.shared.data(for: request)
-
- // The Blob is dropped rather than decoded, and `file` is still the file.
- let received = try #require(uploader.received)
- #expect(received.filename == "photo.jpg")
- #expect(received.fields == [MediaUploadField(name: "post", value: "42")])
- }
-
- @Test("round-trips a non-Latin field value exactly")
- func nonLatinFieldRoundTrips() async throws {
- // The other half: valid UTF-8 round-trips, so real captions and titles survive.
- let uploader = RecordingUploader()
- let server = try await MediaUploadServer.start(uploader: uploader, internalClient: MockInternalMediaClient())
- defer { server.stop() }
-
- let caption = "Grüße 🎉 日本語"
- let boundary = UUID().uuidString
- var body = Data()
- body.append("--\(boundary)\r\n")
- body.append("Content-Disposition: form-data; name=\"caption\"\r\n\r\n")
- body.append("\(caption)\r\n")
- body.append("--\(boundary)\r\n")
- body.append("Content-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n")
- body.append("Content-Type: image/jpeg\r\n\r\n")
- body.append(Data("fake image data".utf8))
- body.append("\r\n--\(boundary)--\r\n")
-
- let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
- request.httpBody = body
-
- _ = try await URLSession.shared.data(for: request)
-
- #expect(uploader.received?.fields == [MediaUploadField(name: "caption", value: caption)])
- }
-
- @Test("a processor still processes the file an uploader delivers")
- func processorRunsForUploader() async throws {
- let processor = ProcessOnlyProcessor()
- let uploader = RecordingUploader()
- let server = try await MediaUploadServer.start(processor: processor, uploader: uploader, internalClient: MockInternalMediaClient())
- defer { server.stop() }
-
- let boundary = UUID().uuidString
- let body = buildMultipartBody(boundary: boundary, filename: "photo.jpg", mimeType: "image/jpeg", data: Data("fake image data".utf8))
- let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
- request.httpBody = body
-
- _ = try await URLSession.shared.data(for: request)
-
- // The processor still processes; only delivery moves to the uploader.
- #expect(processor.processFileCalled)
- #expect(uploader.received != nil)
- }
-
- @Test("an uploader sees a file the processor's metadata gate would have declined")
- func uploaderSeesDeclinedFile() async throws {
- // The gate exists to skip a temp copy for a file the processor won't touch. An
- // uploader takes over delivery for every file, so passing through here would
- // silently bypass it.
- let processor = DeclineByMetadataProcessor()
- let uploader = RecordingUploader()
- let internalClient = MockInternalMediaClient()
- let server = try await MediaUploadServer.start(processor: processor, uploader: uploader, internalClient: internalClient)
- defer { server.stop() }
-
- let boundary = UUID().uuidString
- let body = buildMultipartBody(boundary: boundary, filename: "clip.mov", mimeType: "video/quicktime", data: Data("movie".utf8))
- let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
- request.httpBody = body
-
- _ = try await URLSession.shared.data(for: request)
-
- #expect(uploader.received?.filename == "clip.mov")
- #expect(!internalClient.passthroughUploadCalled)
- // ...but a declined file must still not reach `processFile`: `handlesFile`
- // returning false is the processor saying it won't touch a file like this.
- #expect(!processor.processFileCalled)
- }
-
- @Test("an uploader that throws surfaces as a failure, with no GutenbergKit retry")
- func uploaderThrowSurfaces() async throws {
- let internalClient = MockInternalMediaClient()
- let server = try await MediaUploadServer.start(uploader: ThrowingUploader(), internalClient: internalClient)
- defer { server.stop() }
-
- let boundary = UUID().uuidString
- let body = buildMultipartBody(boundary: boundary, filename: "photo.jpg", mimeType: "image/jpeg", data: Data("fake image data".utf8))
- let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
- request.httpBody = body
-
- let (_, response) = try await URLSession.shared.data(for: request)
- let httpResponse = try #require(response as? HTTPURLResponse)
-
- #expect(httpResponse.statusCode == 500)
- // Recovery is the uploader's, not GutenbergKit's — it must not re-deliver.
- #expect(!internalClient.uploadCalled)
- #expect(!internalClient.passthroughUploadCalled)
- }
-
- @Test("retains the processor for the server's lifetime, and releases it after")
- func retainsProcessorForServerLifetime() async throws {
- weak var weakProcessor: ProcessOnlyProcessor?
- do {
- var processor: ProcessOnlyProcessor? = ProcessOnlyProcessor()
- weakProcessor = processor
- let server = try await MediaUploadServer.start(processor: processor)
- defer { server.stop() }
-
- // The server owns the processor while it runs: the host can assign one and drop
- // its own reference, and every request still sees it. The host reference has to
- // go *before* the assert, or the local satisfies it and the server's ownership
- // is never what is under test — held weakly, this is already nil here.
- processor = nil
- #expect(weakProcessor != nil)
- }
-
- // …and lets go when it stops, so the processor isn't leaked for the process's
- // lifetime. Asserted outright rather than polled: `HTTPServer.stop()` clears the
- // listener's `newConnectionHandler`, which is what holds the handler closure and
- // through it this processor, so the release lands synchronously on this thread
- // instead of trailing an asynchronous `NWListener` cancellation onto its queue.
- #expect(weakProcessor == nil)
- }
-
- @Test("stopping frees a processor that holds the server back")
- func stopReleasesProcessorThatRetainsTheServer() async throws {
- // The server-side half of the ownership story, and the one nothing else covers.
- // `EditorViewController.stopMediaHandling()` clears its own properties *and* stops
- // the server, because releasing only one leaves the loop routed through the other:
- // `listener -> newConnectionHandler -> Handler -> processor -> server`.
- //
- // Polled rather than asserted outright, unlike `retainsProcessorForServerLifetime`:
- // `releaseConnectionHandler()` opens the loop on the caller's thread, but it is not
- // the only thing that does. Cancelling an `NWListener` also releases the blocks it
- // captured, for a deployment target of iOS 16 or later (this package requires 17) —
- // rdar://89677097, documented in the macOS 13 release notes — and that release lands
- // on the listener's own queue. Confirmed by no-op'ing `releaseConnectionHandler()`:
- // the processor is still freed, a poll tick later. Before that OS change the blocks
- // were held for the listener's lifetime, so a lowered deployment target hangs here
- // instead of quietly stranding listeners.
- weak var weakProcessor: ServerRetainingProcessor?
- var server: MediaUploadServer?
-
- do {
- let processor = ServerRetainingProcessor()
- weakProcessor = processor
- let started = try await MediaUploadServer.start(processor: processor)
- processor.server = started // closes the loop: server -> handler -> processor -> server
- server = started
- }
-
- #expect(weakProcessor != nil, "the server should own the processor while it runs")
-
- server?.stop()
- server = nil
-
- for _ in 0..<100 where weakProcessor != nil {
- try await Task.sleep(for: .milliseconds(10))
- }
- #expect(weakProcessor == nil, "processor leaked — stopping did not release the handler's references")
- }
-
- @Test("still processes for a processor the host has dropped its reference to")
- func processesForHostReleasedProcessor() async throws {
- // The processor is read at the admission gate and again at processFile, separated
- // by a synchronous disk copy and an unbounded processFile.
- // Held weakly, a host that dropped its reference changed the answer between
- // those reads: a file admitted for processing was forwarded unprocessed. The
- // host dropping it before the request is the same condition, deterministically.
- let mockUploader = MockInternalMediaClient()
- var processor: ResizingProcessor? = ResizingProcessor()
- weak let weakProcessor = processor
- let server = try await MediaUploadServer.start(processor: processor, internalClient: mockUploader)
- defer { server.stop() }
-
- // Drop the host's only strong reference. Under the documented contract the
- // server owns the processor from here, so the upload must still be processed.
- processor = nil
-
- let boundary = UUID().uuidString
- let body = buildMultipartBody(boundary: boundary, filename: "clip.mov", mimeType: "video/quicktime", data: Data("movie".utf8))
- let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
- request.httpBody = body
-
- _ = try await URLSession.shared.data(for: request)
-
- // The server kept it alive, so the processed metadata reached the uploader.
- // Against a weak container this fails with the real symptom: the passthrough
- // branch runs and the original video/quicktime is forwarded unprocessed.
- #expect(weakProcessor != nil)
- #expect(mockUploader.uploadCalled)
- #expect(mockUploader.lastUploadMimeType == "video/mp4")
- #expect(!mockUploader.passthroughUploadCalled)
- }
-
- private func buildMultipartBody(boundary: String, filename: String, mimeType: String, data: Data) -> Data {
- var body = Data()
- body.append("--\(boundary)\r\n")
- body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n")
- body.append("Content-Type: \(mimeType)\r\n\r\n")
- body.append(data)
- body.append("\r\n--\(boundary)--\r\n")
- return body
- }
-}
-
-// MARK: - Streaming Multipart Body Tests
-
-@Suite("InternalMediaClient streaming multipart body")
-struct MultipartBodyStreamTests {
-
- @Test("streaming output matches in-memory multipart format")
- func streamMatchesInMemory() throws {
- let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("stream-test-\(UUID().uuidString)")
- let fileContent = Data("hello world".utf8)
- try fileContent.write(to: tempFile)
- defer { try? FileManager.default.removeItem(at: tempFile) }
-
- let boundary = "test-boundary-123"
- let filename = "photo.jpg"
- let mimeType = "image/jpeg"
-
- // Build expected output using the old in-memory approach.
- var expected = Data()
- expected.append(Data("--\(boundary)\r\n".utf8))
- expected.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".utf8))
- expected.append(Data("Content-Type: \(mimeType)\r\n\r\n".utf8))
- expected.append(fileContent)
- expected.append(Data("\r\n--\(boundary)--\r\n".utf8))
-
- // Build streaming output.
- let (stream, contentLength) = try InternalMediaClient.multipartBodyStream(
- fileURL: tempFile, boundary: boundary, filename: filename, mimeType: mimeType, extraFields: []
- )
- #expect(contentLength == expected.count)
-
- let result = readAllFromStream(stream)
- #expect(result == expected)
- }
-
- @Test("escapes CR/LF and quotes so a crafted filename can't inject headers or parts")
- func escapesHeaderInjection() throws {
- let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("stream-test-\(UUID().uuidString)")
- try Data("file-bytes".utf8).write(to: tempFile)
- defer { try? FileManager.default.removeItem(at: tempFile) }
-
- // Craft a filename, field name, and MIME type that each try to smuggle a CRLF
- // and a fake header into the body relayed to WordPress.
- let (stream, _) = try InternalMediaClient.multipartBodyStream(
- fileURL: tempFile,
- boundary: "boundary",
- filename: "evil\"\r\nX-Injected-File: 1.jpg",
- mimeType: "image/jpeg\r\nX-Injected-Type: 1",
- extraFields: [("field\"\r\nX-Injected-Name: 1", Data("v".utf8))]
- )
- let text = String(decoding: readAllFromStream(stream), as: UTF8.self)
-
- // None of the crafted CRLF sequences may survive as a real header break.
- #expect(!text.contains("\r\nX-Injected-File:"))
- #expect(!text.contains("\r\nX-Injected-Type:"))
- #expect(!text.contains("\r\nX-Injected-Name:"))
- }
-
- @Test("includes non-file parts (e.g. post) ahead of the file")
- func multipartBodyIncludesExtraParts() throws {
- let boundary = "boundary"
- let filename = "photo.jpg"
- let mimeType = "image/jpeg"
- let fileContent = Data("image bytes".utf8)
- let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("stream-extra-\(UUID().uuidString)")
- try fileContent.write(to: tempFile)
- defer { try? FileManager.default.removeItem(at: tempFile) }
-
- var expected = Data()
- expected.append(Data("--\(boundary)\r\n".utf8))
- expected.append(Data("Content-Disposition: form-data; name=\"post\"\r\n\r\n".utf8))
- expected.append(Data("123\r\n".utf8))
- expected.append(Data("--\(boundary)\r\n".utf8))
- expected.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".utf8))
- expected.append(Data("Content-Type: \(mimeType)\r\n\r\n".utf8))
- expected.append(fileContent)
- expected.append(Data("\r\n--\(boundary)--\r\n".utf8))
-
- let (stream, contentLength) = try InternalMediaClient.multipartBodyStream(
- fileURL: tempFile, boundary: boundary, filename: filename, mimeType: mimeType,
- extraFields: [("post", Data("123".utf8))]
- )
- #expect(contentLength == expected.count)
- #expect(readAllFromStream(stream) == expected)
- }
-
- @Test("forwards a non-UTF-8 field value verbatim")
- func multipartBodyPreservesNonUTF8FieldValue() throws {
- let boundary = "boundary"
- let filename = "photo.jpg"
- let mimeType = "image/jpeg"
- let fileContent = Data("image bytes".utf8)
- let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("stream-binary-\(UUID().uuidString)")
- try fileContent.write(to: tempFile)
- defer { try? FileManager.default.removeItem(at: tempFile) }
-
- // A field value that is not valid UTF-8 (a lone 0xFF byte between ASCII bytes).
- let binaryValue = Data([0x61, 0xFF, 0x62])
-
- var expected = Data()
- expected.append(Data("--\(boundary)\r\n".utf8))
- expected.append(Data("Content-Disposition: form-data; name=\"blob\"\r\n\r\n".utf8))
- expected.append(binaryValue)
- expected.append(Data("\r\n".utf8))
- expected.append(Data("--\(boundary)\r\n".utf8))
- expected.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".utf8))
- expected.append(Data("Content-Type: \(mimeType)\r\n\r\n".utf8))
- expected.append(fileContent)
- expected.append(Data("\r\n--\(boundary)--\r\n".utf8))
-
- let (stream, contentLength) = try InternalMediaClient.multipartBodyStream(
- fileURL: tempFile, boundary: boundary, filename: filename, mimeType: mimeType,
- extraFields: [("blob", binaryValue)]
- )
- #expect(contentLength == expected.count)
- // The raw 0xFF byte survives — it was not coerced through String.
- #expect(readAllFromStream(stream) == expected)
- }
-
- @Test("content length matches actual stream output for larger files")
- func contentLengthAccurate() throws {
- let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("stream-test-\(UUID().uuidString)")
- let fileContent = Data(repeating: 0x42, count: 100_000)
- try fileContent.write(to: tempFile)
- defer { try? FileManager.default.removeItem(at: tempFile) }
-
- let (stream, contentLength) = try InternalMediaClient.multipartBodyStream(
- fileURL: tempFile, boundary: "boundary", filename: "big.bin", mimeType: "application/octet-stream", extraFields: []
- )
-
- let result = readAllFromStream(stream)
- #expect(result.count == contentLength)
- }
-
- @Test("writeMultipartBody streams the full body and closing boundary when the file reads cleanly")
- func writeMultipartBodyWritesFullBody() throws {
- let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("wmb-\(UUID().uuidString)")
- let fileContent = Data("the file bytes".utf8)
- try fileContent.write(to: tempFile)
- defer { try? FileManager.default.removeItem(at: tempFile) }
-
- let fileHandle = try FileHandle(forReadingFrom: tempFile)
- defer { try? fileHandle.close() }
-
- let output = OutputStream.toMemory()
- output.open()
- defer { output.close() }
-
- let preamble = Data("PREAMBLE".utf8)
- let epilogue = Data("EPILOGUE".utf8)
- let ok = InternalMediaClient.writeMultipartBody(
- fileHandle: fileHandle, fileSize: fileContent.count,
- preamble: preamble, epilogue: epilogue, to: output
- )
-
- #expect(ok)
- let written = output.property(forKey: .dataWrittenToMemoryStreamKey) as? Data
- #expect(written == preamble + fileContent + epilogue)
- }
-
- @Test("writeMultipartBody aborts without the closing boundary when the file is shorter than measured")
- func writeMultipartBodyAbortsOnShortFile() throws {
- let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("wmb-short-\(UUID().uuidString)")
- let fileContent = Data("only ten!!".utf8) // 10 bytes
- try fileContent.write(to: tempFile)
- defer { try? FileManager.default.removeItem(at: tempFile) }
-
- let fileHandle = try FileHandle(forReadingFrom: tempFile)
- defer { try? fileHandle.close() }
-
- let output = OutputStream.toMemory()
- output.open()
- defer { output.close() }
-
- let preamble = Data("PREAMBLE".utf8)
- let epilogue = Data("EPILOGUE".utf8)
- // Claim the file is larger than it is, as if it shrank after being measured.
- let ok = InternalMediaClient.writeMultipartBody(
- fileHandle: fileHandle, fileSize: fileContent.count + 100,
- preamble: preamble, epilogue: epilogue, to: output
- )
-
- #expect(!ok)
- // The preamble and the real file bytes were written, but NOT the closing
- // boundary — a short body must not masquerade as a complete multipart.
- let written = (output.property(forKey: .dataWrittenToMemoryStreamKey) as? Data) ?? Data()
- #expect(written == preamble + fileContent)
- }
-}
-
-// MARK: - InternalMediaClient Relay Tests
-
-@Suite("InternalMediaClient relay")
-struct InternalMediaClientRelayTests {
-
- @Test("relays a non-2xx WordPress response instead of throwing")
- func relaysErrorResponseVerbatim() async throws {
- // A WordPress REST error body, returned with a non-2xx status.
- let errorBody = Data(#"{"code":"rest_cannot_create","message":"Sorry, you are not allowed to upload this file type."}"#.utf8)
- let client = RelayStubHTTPClient(statusCode: 403, body: errorBody)
- let uploader = InternalMediaClient(httpClient: client, siteApiRoot: URL(string: "https://example.com/wp-json/")!)
-
- let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("relay-\(UUID().uuidString).jpg")
- try Data("fake image".utf8).write(to: tempFile)
- defer { try? FileManager.default.removeItem(at: tempFile) }
-
- // performUpload must route through performRaw, which does NOT validate status,
- // so WordPress's 403 + body flow through verbatim. A revert to perform() would
- // throw on the non-2xx (RelayStubHTTPClient.perform mirrors that), failing here.
- let response = try await uploader.upload(
- fileURL: tempFile, mimeType: "image/jpeg", filename: "photo.jpg", extraParts: [], query: ""
- )
-
- #expect(response.statusCode == 403)
- #expect(response.body == errorBody)
- }
-
- @Test("relays the upload attachment ID header from a failed upload")
- func relaysUploadAttachmentIdHeader() async throws {
- // WordPress sets this header on an upload whose attachment row was created
- // before metadata generation fataled. The editor reads it to retry
- // post-process and clean up the orphan, so it must survive the relay.
- let client = RelayStubHTTPClient(
- statusCode: 500,
- body: Data(#"{"code":"rest_upload_error"}"#.utf8),
- headerFields: ["x-wp-upload-attachment-id": "4242"]
- )
- let uploader = InternalMediaClient(
- httpClient: client, siteApiRoot: URL(string: "https://example.com/wp-json/")!)
-
- let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent(
- "header-\(UUID().uuidString).jpg")
- try Data("fake image".utf8).write(to: tempFile)
- defer { try? FileManager.default.removeItem(at: tempFile) }
-
- let response = try await uploader.upload(
- fileURL: tempFile, mimeType: "image/jpeg", filename: "photo.jpg", extraParts: [], query: ""
- )
-
- #expect(response.statusCode == 500)
- #expect(response.headers["x-wp-upload-attachment-id"] == "4242")
- }
-
- @Test("omits unrelated upstream headers from the relay")
- func omitsUnrelatedHeaders() async throws {
- let client = RelayStubHTTPClient(
- statusCode: 201,
- body: Data("{}".utf8),
- headerFields: ["X-Powered-By": "PHP/8.2", "Set-Cookie": "session=secret"]
- )
- let uploader = InternalMediaClient(
- httpClient: client, siteApiRoot: URL(string: "https://example.com/wp-json/")!)
-
- let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent(
- "unrelated-\(UUID().uuidString).jpg")
- try Data("fake image".utf8).write(to: tempFile)
- defer { try? FileManager.default.removeItem(at: tempFile) }
-
- let response = try await uploader.upload(
- fileURL: tempFile, mimeType: "image/jpeg", filename: "photo.jpg", extraParts: [], query: ""
- )
-
- #expect(response.headers.isEmpty)
- }
-
- @Test("deletes an attachment, carrying the namespace and force query")
- func deletesAttachment() async throws {
- let client = URLCapturingHTTPClient()
- let uploader = InternalMediaClient(
- httpClient: client,
- siteApiRoot: URL(string: "https://example.com/wp-json")!,
- siteApiNamespace: ["sites/123"]
- )
-
- _ = try await uploader.deleteMedia(attachmentId: "42", query: "?force=true")
-
- let url = try #require(client.lastURL)
- #expect(
- url.absoluteString == "https://example.com/wp-json/wp/v2/sites/123/media/42?force=true")
- }
-
- @Test("carries the namespace and request query through to the media endpoint")
- func forwardsNamespaceAndQuery() async throws {
- let client = URLCapturingHTTPClient()
- let uploader = InternalMediaClient(
- httpClient: client,
- siteApiRoot: URL(string: "https://example.com/wp-json")!,
- siteApiNamespace: ["sites/123"]
- )
- let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("query-\(UUID().uuidString).jpg")
- try Data("img".utf8).write(to: tempFile)
- defer { try? FileManager.default.removeItem(at: tempFile) }
-
- _ = try await uploader.upload(
- fileURL: tempFile, mimeType: "image/jpeg", filename: "photo.jpg",
- extraParts: [], query: "?_embed=wp:featuredmedia"
- )
-
- // Namespace inserted via the shared builder, and the query preserved verbatim —
- // including the `:`, which would make URL(string:) return nil and drop it (#6).
- let url = try #require(client.lastURL)
- #expect(url.absoluteString == "https://example.com/wp-json/wp/v2/sites/123/media?_embed=wp:featuredmedia")
- }
-}
-
-/// An HTTP client whose `performRaw` relays a canned response without validating
-/// status, while `perform` throws on a non-2xx — mirroring the real
-/// `EditorHTTPClient`. Lets a test prove `InternalMediaClient` routes uploads
-/// through `performRaw` (relay) rather than `perform` (throw).
-private struct RelayStubHTTPClient: EditorHTTPClientProtocol {
- let statusCode: Int
- let body: Data
- var headerFields: [String: String]?
-
- func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) {
- let response = HTTPURLResponse(
- url: urlRequest.url!, statusCode: statusCode, httpVersion: nil, headerFields: headerFields)!
- guard (200...299).contains(statusCode) else {
- throw NSError(domain: "RelayStubHTTPClient", code: statusCode)
- }
- return (body, response)
- }
-
- func performRaw(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) {
- let response = HTTPURLResponse(
- url: urlRequest.url!, statusCode: statusCode, httpVersion: nil, headerFields: headerFields)!
- return (body, response)
- }
-
- func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) {
- let response = HTTPURLResponse(url: urlRequest.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)!
- return (FileManager.default.temporaryDirectory, response)
- }
-}
-
-/// Captures the URL of the last request so a test can assert the media endpoint
-/// URL (namespace + query) the uploader built.
-private final class URLCapturingHTTPClient: EditorHTTPClientProtocol, @unchecked Sendable {
- private let lock = NSLock()
- private var _lastURL: URL?
- var lastURL: URL? { lock.withLock { _lastURL } }
-
- private func ok(_ urlRequest: URLRequest) -> (Data, HTTPURLResponse) {
- lock.withLock { _lastURL = urlRequest.url }
- return (Data("{}".utf8), HTTPURLResponse(url: urlRequest.url!, statusCode: 201, httpVersion: nil, headerFields: nil)!)
- }
-
- func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { ok(urlRequest) }
- func performRaw(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { ok(urlRequest) }
- func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) {
- let (_, response) = ok(urlRequest)
- return (FileManager.default.temporaryDirectory, response)
- }
-}
-
-// MARK: - Helpers
-
-/// Reads all bytes from an InputStream using `read()` return value as
-/// the sole termination signal (not `hasBytesAvailable`, which is
-/// unreliable for piped/bound streams).
-private func readAllFromStream(_ stream: InputStream) -> Data {
- stream.open()
- defer { stream.close() }
-
- var data = Data()
- let bufferSize = 8192
- let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize)
- defer { buffer.deallocate() }
- while true {
- let read = stream.read(buffer, maxLength: bufferSize)
- if read <= 0 { break }
- data.append(buffer, count: read)
- }
- return data
-}
-
-// MARK: - Mocks
-
-/// Records the ``MediaUpload`` it is handed, and returns a finished attachment.
-private final class RecordingUploader: MediaUploader, @unchecked Sendable {
- private let lock = NSLock()
- private var _received: MediaUpload?
-
- var received: MediaUpload? { lock.withLock { _received } }
-
- func upload(_ upload: MediaUpload) async throws -> Data {
- lock.withLock { _received = upload }
- // Shaped like a real attachment: the editor's `transformAttachment` reads
- // `title.raw`, so an example without it would model a body that fails in the
- // editor.
- return Data(#"{"id":7,"source_url":"https://example.com/photo.jpg","media_type":"image","title":{"raw":"photo"},"caption":{"raw":""}}"#.utf8)
- }
-}
-
-/// An uploader whose delivery fails terminally, as one would after exhausting its own
-/// post-process recovery and force-deleting the orphan.
-private final class ThrowingUploader: MediaUploader {
- struct Failure: Error {}
-
- func upload(_ upload: MediaUpload) async throws -> Data {
- throw Failure()
- }
-}
-
-private final class ProcessOnlyProcessor: MediaProcessor, @unchecked Sendable {
- private let lock = NSLock()
- private var _processFileCalled = false
-
- var processFileCalled: Bool { lock.withLock { _processFileCalled } }
-
- func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile {
- lock.withLock { _processFileCalled = true }
- return .original
- }
-}
-
-/// A processor that declines every file by metadata via `handlesFile`. With no
-/// uploader the server must pass through without ever materializing the file; with
-/// one, delivery still happens but `processFile` must not be called.
-/// `processFileCalled` pins both.
-private final class DeclineByMetadataProcessor: MediaProcessor, @unchecked Sendable {
- private let lock = NSLock()
- private var _processFileCalled = false
-
- var processFileCalled: Bool { lock.withLock { _processFileCalled } }
-
- func handlesFile(ofType mimeType: String, named filename: String) -> Bool { false }
-
- func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile {
- lock.withLock { _processFileCalled = true }
- return .original
- }
-}
-
-/// A processor that produces a new file with changed metadata (e.g. a transcode).
-/// A value-type processor. `struct`, and `Sendable` without `@unchecked` — both are the
-/// point: this is the shape ``MediaProcessor``'s documentation now recommends.
-private struct ValueTypeProcessor: MediaProcessor {
- func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile {
- let processed = url.deletingLastPathComponent()
- .appending(component: "value-\(UUID().uuidString).mp4")
- try Data("transcoded".utf8).write(to: processed)
- return .processed(processed, mimeType: "video/mp4", filename: "clip.mp4")
- }
-}
-
-private final class ResizingProcessor: MediaProcessor, @unchecked Sendable {
- private let lock = NSLock()
- private var _producedURL: URL?
-
- /// The URL of the processed file this processor wrote, for cleanup assertions.
- var producedURL: URL? { lock.withLock { _producedURL } }
-
- func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile {
- let newURL = url.deletingLastPathComponent().appending(component: "processed-\(UUID().uuidString)")
- try Data("processed".utf8).write(to: newURL)
- lock.withLock { _producedURL = newURL }
- return .processed(newURL, mimeType: "video/mp4", filename: "clip.mp4")
- }
-}
-
-private final class MockInternalMediaClient: InternalMediaClient, @unchecked Sendable {
- private let lock = NSLock()
- private var _uploadCalled = false
- private var _passthroughUploadCalled = false
- private var _lastUploadMimeType: String?
- private var _lastUploadFilename: String?
- private var _lastQuery: String?
-
- var uploadCalled: Bool { lock.withLock { _uploadCalled } }
- var passthroughUploadCalled: Bool { lock.withLock { _passthroughUploadCalled } }
- var lastUploadMimeType: String? { lock.withLock { _lastUploadMimeType } }
- var lastUploadFilename: String? { lock.withLock { _lastUploadFilename } }
- var lastQuery: String? { lock.withLock { _lastQuery } }
-
- init() {
- super.init(httpClient: MockHTTPClient(), siteApiRoot: URL(string: "https://example.com/wp-json/")!)
- }
-
- override func upload(fileURL: URL, mimeType: String, filename: String, extraParts: [MultipartPart], query: String) async throws -> MediaUploadResponse {
- lock.withLock {
- _uploadCalled = true
- _lastUploadMimeType = mimeType
- _lastUploadFilename = filename
- _lastQuery = query
- }
- return mockResponse()
- }
-
- override func passthroughUpload(body: RequestBody, contentType: String, query: String) async throws -> MediaUploadResponse {
- lock.withLock {
- _passthroughUploadCalled = true
- _lastQuery = query
- }
- return mockResponse()
- }
-
- private func mockResponse() -> MediaUploadResponse {
- let json = #"{"id":99,"source_url":"https://example.com/doc.pdf","media_type":"file"}"#
- return MediaUploadResponse(statusCode: 201, body: Data(json.utf8))
- }
-}
-
-/// An internal media client whose delete response carries its own `Content-Type`, so the
-/// relay must override the JSON default rather than emit the header twice.
-private final class ContentTypeDeleteClient: InternalMediaClient, @unchecked Sendable {
- init() {
- super.init(httpClient: MockHTTPClient(), siteApiRoot: URL(string: "https://example.com/wp-json/")!)
- }
-
- override func deleteMedia(attachmentId: String, query: String) async throws -> MediaUploadResponse {
- MediaUploadResponse(
- statusCode: 200,
- body: Data("deleted".utf8),
- headers: ["Content-Type": "text/plain"]
- )
- }
-}
-
-private struct MockHTTPClient: EditorHTTPClientProtocol {
- func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) {
- let response = HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
- return (Data(), response)
- }
-
- func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) {
- let response = HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
- return (FileManager.default.temporaryDirectory, response)
- }
-}
-
-private extension Data {
- mutating func append(_ string: String) {
- append(string.data(using: .utf8)!)
- }
-}
-
-/// Holds the server that owns it, closing `server -> handler -> processor -> server`.
-/// Only `stop()` — which drops the listener's captured blocks — opens it.
-private final class ServerRetainingProcessor: MediaProcessor, @unchecked Sendable {
- var server: MediaUploadServer?
-
- func handlesFile(ofType mimeType: String, named filename: String) -> Bool { false }
-
- func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile {
- .original
- }
-}
diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServiceTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServiceTests.swift
new file mode 100644
index 000000000..d89c45183
--- /dev/null
+++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServiceTests.swift
@@ -0,0 +1,213 @@
+import Foundation
+import Testing
+
+@testable import GutenbergKit
+
+@Suite("MediaUploadService")
+struct MediaUploadServiceTests {
+ private let fields = [MediaUploadField(name: "post", value: "7"), MediaUploadField(name: "field[]", value: "a")]
+
+ private func file(_ contents: String = "original bytes", mimeType: String = "image/jpeg") throws -> MediaUploadFile {
+ MediaUploadFile(url: try makeTemporaryFile(Data(contents.utf8)), mimeType: mimeType, filename: "photo.jpg")
+ }
+
+ // MARK: - GutenbergKit's own client
+
+ @Test("uploads the original file, fields, and query through the internal client")
+ func uploadsThroughTheInternalClient() async throws {
+ let client = RecordingInternalMediaClient()
+ let service = MediaUploadService(processor: nil, uploader: nil, internalClient: client)
+ let file = try file()
+
+ let response = try await service.upload(file, fields: fields, query: "?_embed=wp:featuredmedia")
+
+ #expect(response.statusCode == 201)
+ let upload = try #require(client.uploads.first)
+ #expect(upload.fileURL == file.url)
+ #expect(upload.contents == Data("original bytes".utf8))
+ #expect(upload.fields == fields)
+ #expect(upload.query == "?_embed=wp:featuredmedia")
+ }
+
+ @Test("uploads the processor's file with the processor's metadata, then deletes it")
+ func uploadsTheProcessedFile() async throws {
+ let client = RecordingInternalMediaClient()
+ let processor = ResizingProcessor()
+ let service = MediaUploadService(processor: processor, uploader: nil, internalClient: client)
+ let file = try file()
+
+ _ = try await service.upload(file, fields: fields, query: "")
+
+ let upload = try #require(client.uploads.first)
+ #expect(upload.contents == Data("processed".utf8))
+ #expect(upload.mimeType == "video/mp4")
+ #expect(upload.filename == "clip.mp4")
+ let produced = try #require(processor.producedURL)
+ #expect(!FileManager.default.fileExists(atPath: produced.path), "the processed file outlived the upload")
+ #expect(FileManager.default.fileExists(atPath: file.url.path), "the service deleted a file it doesn't own")
+ }
+
+ @Test("a value-type processor is admitted, called, and its result delivered")
+ func valueTypeProcessor() async throws {
+ let client = RecordingInternalMediaClient()
+ let service = MediaUploadService(processor: ValueTypeProcessor(), uploader: nil, internalClient: client)
+
+ _ = try await service.upload(try file(), fields: [], query: "")
+
+ #expect(client.uploads.first?.contents == Data("transcoded".utf8))
+ }
+
+ @Test("skips processFile for a file the processor declines by metadata")
+ func declinedByMetadata() async throws {
+ let client = RecordingInternalMediaClient()
+ let processor = DeclineByMetadataProcessor()
+ let service = MediaUploadService(processor: processor, uploader: nil, internalClient: client)
+
+ _ = try await service.upload(try file(), fields: [], query: "")
+
+ #expect(!processor.processFileCalled)
+ #expect(client.uploads.first?.contents == Data("original bytes".utf8))
+ }
+
+ @Test("relays a non-2xx WordPress response instead of throwing")
+ func relaysWordPressErrors() async throws {
+ let client = RecordingInternalMediaClient(response: MediaUploadResponse(
+ statusCode: 500,
+ body: Data(#"{"code":"rest_upload_sideload_error"}"#.utf8),
+ headers: ["x-wp-upload-attachment-id": "42"]
+ ))
+ let service = MediaUploadService(processor: nil, uploader: nil, internalClient: client)
+
+ let response = try await service.upload(try file(), fields: [], query: "")
+
+ #expect(response.statusCode == 500)
+ #expect(response.headers["x-wp-upload-attachment-id"] == "42")
+ }
+
+ // MARK: - The host's uploader
+
+ @Test("an uploader performs the upload and receives the fields and query")
+ func uploaderReceivesFieldsAndQuery() async throws {
+ let uploader = RecordingUploader()
+ let client = RecordingInternalMediaClient()
+ let service = MediaUploadService(processor: nil, uploader: uploader, internalClient: client)
+
+ let response = try await service.upload(try file(), fields: fields, query: "?_embed")
+
+ #expect(response.statusCode == 201)
+ #expect(String(decoding: response.body, as: UTF8.self).contains(#""id":7"#))
+ #expect(uploader.received?.fields == fields)
+ #expect(uploader.received?.query == "?_embed")
+ #expect(client.uploads.isEmpty, "GutenbergKit uploaded a file the host's uploader owns")
+ }
+
+ @Test("a processor still processes the file an uploader delivers")
+ func processorBeforeUploader() async throws {
+ let uploader = RecordingUploader()
+ let service = MediaUploadService(processor: ResizingProcessor(), uploader: uploader, internalClient: nil)
+
+ _ = try await service.upload(try file(), fields: [], query: "")
+
+ #expect(uploader.receivedContents == Data("processed".utf8))
+ #expect(uploader.received?.mimeType == "video/mp4")
+ }
+
+ @Test("an uploader sees a file the processor's metadata gate declined, unprocessed")
+ func uploaderGetsDeclinedFile() async throws {
+ let uploader = RecordingUploader()
+ let processor = DeclineByMetadataProcessor()
+ let service = MediaUploadService(processor: processor, uploader: uploader, internalClient: nil)
+
+ _ = try await service.upload(try file(), fields: [], query: "")
+
+ #expect(!processor.processFileCalled)
+ #expect(uploader.receivedContents == Data("original bytes".utf8))
+ }
+
+ @Test("an uploader that throws surfaces as a failure, with no GutenbergKit retry")
+ func throwingUploader() async throws {
+ let client = RecordingInternalMediaClient()
+ let service = MediaUploadService(processor: nil, uploader: ThrowingUploader(), internalClient: client)
+
+ await #expect(throws: ThrowingUploader.Failure.self) {
+ _ = try await service.upload(try file(), fields: [], query: "")
+ }
+ #expect(client.uploads.isEmpty)
+ }
+
+ // MARK: - Cancellation and configuration
+
+ @Test("doesn't start an upload once the task is cancelled")
+ func cancelledBeforeDelivery() async throws {
+ let client = RecordingInternalMediaClient()
+ let gate = AsyncGate()
+ let service = MediaUploadService(processor: GatedProcessor(gate: gate), uploader: nil, internalClient: client)
+ let file = try file()
+
+ let upload = Task { try await service.upload(file, fields: [], query: "") }
+ await gate.waitUntilEntered()
+ upload.cancel()
+ await gate.open()
+
+ await #expect(throws: CancellationError.self) { _ = try await upload.value }
+ #expect(client.uploads.isEmpty, "uploaded a file nobody will read the response for")
+ }
+
+ @Test("fails without an uploader or an internal client")
+ func noUploader() async throws {
+ let service = MediaUploadService(processor: nil, uploader: nil, internalClient: nil)
+
+ await #expect(throws: UploadError.self) {
+ _ = try await service.upload(try file(), fields: [], query: "")
+ }
+ }
+
+ @Test("deletes through the internal client, carrying the query")
+ func deletes() async throws {
+ let client = RecordingInternalMediaClient()
+ let service = MediaUploadService(processor: nil, uploader: RecordingUploader(), internalClient: client)
+
+ let response = try await service.delete(attachmentId: "42", query: "?force=true")
+
+ #expect(response.statusCode == 200)
+ #expect(client.deletes.first?.id == "42")
+ #expect(client.deletes.first?.query == "?force=true")
+ }
+}
+
+/// A one-shot gate: a waiter parks until someone opens it.
+actor AsyncGate {
+ private var isOpen = false
+ private var entered = false
+ private var waiters: [CheckedContinuation] = []
+ private var enterWaiters: [CheckedContinuation] = []
+
+ func enter() async {
+ entered = true
+ enterWaiters.forEach { $0.resume() }
+ enterWaiters.removeAll()
+ guard !isOpen else { return }
+ await withCheckedContinuation { waiters.append($0) }
+ }
+
+ func waitUntilEntered() async {
+ guard !entered else { return }
+ await withCheckedContinuation { enterWaiters.append($0) }
+ }
+
+ func open() {
+ isOpen = true
+ waiters.forEach { $0.resume() }
+ waiters.removeAll()
+ }
+}
+
+/// A processor that parks in `processFile` until its gate opens.
+struct GatedProcessor: MediaProcessor {
+ let gate: AsyncGate
+
+ func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile {
+ await gate.enter()
+ return .original
+ }
+}
diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadSessionStoreTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadSessionStoreTests.swift
new file mode 100644
index 000000000..f494b7dfa
--- /dev/null
+++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadSessionStoreTests.swift
@@ -0,0 +1,148 @@
+import Foundation
+import Testing
+
+@testable import GutenbergKit
+
+@Suite("MediaUploadSessionStore")
+struct MediaUploadSessionStoreTests {
+ private func makeStore(maxFileSize: Int = MediaUploadSessionStore.defaultMaxFileSize) -> MediaUploadSessionStore {
+ MediaUploadSessionStore(
+ directory: FileManager.default.temporaryDirectory.appending(component: "store-tests-\(UUID().uuidString)"),
+ maxFileSize: maxFileSize
+ )
+ }
+
+ @Test("assembles the chunks it receives into the file, in order")
+ func assemblesChunks() async throws {
+ let store = makeStore()
+ let id = try await store.begin(filename: "clip.mp4", mimeType: "video/mp4", expectedSize: 10)
+
+ #expect(try await store.append(Data("01234".utf8), to: id, at: 0) == 5)
+ #expect(try await store.append(Data("56789".utf8), to: id, at: 5) == 10)
+ let finished = try await store.take(id)
+ defer { finished.cleanUp() }
+
+ #expect(finished.file.filename == "clip.mp4")
+ #expect(finished.file.mimeType == "video/mp4")
+ #expect(try Data(contentsOf: finished.file.url) == Data("0123456789".utf8))
+ }
+
+ @Test("refuses a chunk that doesn't start where the last one ended")
+ func refusesOutOfOrderChunks() async throws {
+ let store = makeStore()
+ let id = try await store.begin(filename: "a.jpg", mimeType: "image/jpeg", expectedSize: nil)
+ _ = try await store.append(Data("abc".utf8), to: id, at: 0)
+
+ await #expect(throws: MediaUploadSessionStore.Failure.offsetMismatch(expected: 3, offset: 0)) {
+ _ = try await store.append(Data("abc".utf8), to: id, at: 0)
+ }
+ await #expect(throws: MediaUploadSessionStore.Failure.offsetMismatch(expected: 3, offset: 9)) {
+ _ = try await store.append(Data("abc".utf8), to: id, at: 9)
+ }
+ }
+
+ @Test("refuses a file announced as larger than the limit")
+ func refusesAnnouncedOversize() async throws {
+ let store = makeStore(maxFileSize: 8)
+
+ await #expect(throws: MediaUploadSessionStore.Failure.tooLarge(limit: 8)) {
+ _ = try await store.begin(filename: "a.jpg", mimeType: "image/jpeg", expectedSize: 9)
+ }
+ }
+
+ @Test("abandons a session whose chunks outgrow the limit")
+ func abandonsOversizedSession() async throws {
+ let store = makeStore(maxFileSize: 8)
+ let id = try await store.begin(filename: "a.jpg", mimeType: "image/jpeg", expectedSize: nil)
+ _ = try await store.append(Data("12345".utf8), to: id, at: 0)
+
+ await #expect(throws: MediaUploadSessionStore.Failure.tooLarge(limit: 8)) {
+ _ = try await store.append(Data("6789".utf8), to: id, at: 5)
+ }
+ await #expect(throws: MediaUploadSessionStore.Failure.unknownSession) { _ = try await store.take(id) }
+ }
+
+ @Test("refuses more bytes than the page announced")
+ func refusesMoreThanAnnounced() async throws {
+ let store = makeStore()
+ let id = try await store.begin(filename: "a.jpg", mimeType: "image/jpeg", expectedSize: 3)
+
+ await #expect(throws: MediaUploadSessionStore.Failure.tooLarge(limit: 3)) {
+ _ = try await store.append(Data("abcd".utf8), to: id, at: 0)
+ }
+ }
+
+ @Test("refuses to finish a file that is missing bytes, and deletes what arrived")
+ func refusesIncompleteFile() async throws {
+ let store = makeStore()
+ let id = try await store.begin(filename: "a.jpg", mimeType: "image/jpeg", expectedSize: 6)
+ _ = try await store.append(Data("abc".utf8), to: id, at: 0)
+
+ await #expect(throws: MediaUploadSessionStore.Failure.incomplete(expected: 6, received: 3)) {
+ _ = try await store.take(id)
+ }
+ await #expect(throws: MediaUploadSessionStore.Failure.unknownSession) { _ = try await store.take(id) }
+ }
+
+ @Test("finishes a session only once")
+ func sessionsAreOneShot() async throws {
+ let store = makeStore()
+ let id = try await store.begin(filename: "a.jpg", mimeType: "image/jpeg", expectedSize: 0)
+
+ let finished = try await store.take(id)
+ finished.cleanUp()
+ await #expect(throws: MediaUploadSessionStore.Failure.unknownSession) { _ = try await store.take(id) }
+ }
+
+ @Test("discarding a session deletes its staging copy")
+ func discardDeletes() async throws {
+ let store = makeStore()
+ let id = try await store.begin(filename: "a.jpg", mimeType: "image/jpeg", expectedSize: nil)
+ _ = try await store.append(Data("abc".utf8), to: id, at: 0)
+ let staged = await store.directory.appending(component: id)
+
+ await store.discard(id)
+
+ #expect(!FileManager.default.fileExists(atPath: staged.path))
+ #expect(await store.sessionCount == 0)
+ }
+
+ @Test("sweeps sessions that have sat idle")
+ func sweepsIdleSessions() async throws {
+ let store = makeStore()
+ _ = try await store.begin(filename: "a.jpg", mimeType: "image/jpeg", expectedSize: nil)
+
+ await store.sweep(idleFor: 3600)
+ #expect(await store.sessionCount == 1, "swept a session that is still in use")
+
+ await store.sweep(idleFor: -1)
+ #expect(await store.sessionCount == 0)
+ }
+
+ @Test("removeAll abandons every session and its directory")
+ func removeAll() async throws {
+ let store = makeStore()
+ _ = try await store.begin(filename: "a.jpg", mimeType: "image/jpeg", expectedSize: nil)
+ _ = try await store.begin(filename: "b.jpg", mimeType: "image/jpeg", expectedSize: nil)
+
+ await store.removeAll()
+
+ #expect(await store.sessionCount == 0)
+ #expect(!FileManager.default.fileExists(atPath: await store.directory.path))
+ }
+
+ @Test("keeps a crafted filename inside the session's directory")
+ func sanitizesFilenames() async throws {
+ #expect(MediaUploadSessionStore.sanitizeFilename("../../etc/passwd") == "passwd")
+ #expect(MediaUploadSessionStore.sanitizeFilename("..") == "upload")
+ #expect(MediaUploadSessionStore.sanitizeFilename("") == "upload")
+ #expect(MediaUploadSessionStore.sanitizeFilename("a\\b.jpg") == "ab.jpg")
+
+ let store = makeStore()
+ let id = try await store.begin(filename: "../../escape.jpg", mimeType: "image/jpeg", expectedSize: 0)
+ let finished = try await store.take(id)
+ defer { finished.cleanUp() }
+ #expect(finished.file.url.deletingLastPathComponent().lastPathComponent == id)
+ #expect(finished.file.filename == "../../escape.jpg", "the filename sent to WordPress should be the page's")
+ }
+}
diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadTestDoubles.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadTestDoubles.swift
new file mode 100644
index 000000000..54a231862
--- /dev/null
+++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadTestDoubles.swift
@@ -0,0 +1,288 @@
+import Foundation
+import Testing
+
+@testable import GutenbergKit
+
+// Test doubles shared by the native media upload suites.
+
+// MARK: - HTTP clients
+
+/// An HTTP client whose `performRaw` relays a canned response without validating
+/// status, while `perform` throws on a non-2xx — mirroring the real
+/// `EditorHTTPClient`. Lets a test prove `InternalMediaClient` routes uploads
+/// through `performRaw` (relay) rather than `perform` (throw).
+struct RelayStubHTTPClient: EditorHTTPClientProtocol {
+ let statusCode: Int
+ let body: Data
+ var headerFields: [String: String]?
+
+ func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) {
+ let response = HTTPURLResponse(
+ url: urlRequest.url!, statusCode: statusCode, httpVersion: nil, headerFields: headerFields)!
+ guard (200...299).contains(statusCode) else {
+ throw NSError(domain: "RelayStubHTTPClient", code: statusCode)
+ }
+ return (body, response)
+ }
+
+ func performRaw(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) {
+ let response = HTTPURLResponse(
+ url: urlRequest.url!, statusCode: statusCode, httpVersion: nil, headerFields: headerFields)!
+ return (body, response)
+ }
+
+ func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) {
+ let response = HTTPURLResponse(url: urlRequest.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)!
+ return (FileManager.default.temporaryDirectory, response)
+ }
+}
+
+/// Captures the URL of the last request so a test can assert the media endpoint
+/// URL (namespace + query) the uploader built.
+final class URLCapturingHTTPClient: EditorHTTPClientProtocol, @unchecked Sendable {
+ private let lock = NSLock()
+ private var _lastURL: URL?
+ var lastURL: URL? { lock.withLock { _lastURL } }
+
+ private func ok(_ urlRequest: URLRequest) -> (Data, HTTPURLResponse) {
+ lock.withLock { _lastURL = urlRequest.url }
+ return (Data("{}".utf8), HTTPURLResponse(url: urlRequest.url!, statusCode: 201, httpVersion: nil, headerFields: nil)!)
+ }
+
+ func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { ok(urlRequest) }
+ func performRaw(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { ok(urlRequest) }
+ func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) {
+ let (_, response) = ok(urlRequest)
+ return (FileManager.default.temporaryDirectory, response)
+ }
+}
+
+/// Drains and records the body of the last request.
+final class BodyCapturingHTTPClient: EditorHTTPClientProtocol, @unchecked Sendable {
+ private let lock = NSLock()
+ private var _lastBody: Data?
+ var lastBody: Data? { lock.withLock { _lastBody } }
+
+ func performRaw(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) {
+ let body = urlRequest.httpBodyStream.map(readAllFromStream) ?? urlRequest.httpBody ?? Data()
+ lock.withLock { _lastBody = body }
+ return (Data("{}".utf8), HTTPURLResponse(url: urlRequest.url!, statusCode: 201, httpVersion: nil, headerFields: nil)!)
+ }
+
+ func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { try await performRaw(urlRequest) }
+ func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) {
+ (FileManager.default.temporaryDirectory, HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
+ }
+}
+
+struct InertHTTPClient: EditorHTTPClientProtocol {
+ func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) {
+ (Data(), HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
+ }
+
+ func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) {
+ (FileManager.default.temporaryDirectory, HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
+ }
+}
+
+/// Reads all bytes from an InputStream using `read()` return value as
+/// the sole termination signal (not `hasBytesAvailable`, which is
+/// unreliable for piped/bound streams).
+func readAllFromStream(_ stream: InputStream) -> Data {
+ stream.open()
+ defer { stream.close() }
+
+ var data = Data()
+ let bufferSize = 8192
+ let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize)
+ defer { buffer.deallocate() }
+ while true {
+ let read = stream.read(buffer, maxLength: bufferSize)
+ if read <= 0 { break }
+ data.append(buffer, count: read)
+ }
+ return data
+}
+
+// MARK: - Internal media client
+
+/// Records what it was asked to upload or delete, and answers like WordPress.
+final class RecordingInternalMediaClient: InternalMediaClient, @unchecked Sendable {
+ struct Upload: Equatable {
+ let fileURL: URL
+ let contents: Data
+ let mimeType: String
+ let filename: String
+ let fields: [MediaUploadField]
+ let query: String
+ }
+
+ private let lock = NSLock()
+ private var _uploads: [Upload] = []
+ private var _deletes: [(id: String, query: String)] = []
+ private let response: MediaUploadResponse
+
+ var uploads: [Upload] { lock.withLock { _uploads } }
+ var deletes: [(id: String, query: String)] { lock.withLock { _deletes } }
+
+ init(response: MediaUploadResponse = MediaUploadResponse(
+ statusCode: 201,
+ body: Data(#"{"id":99,"source_url":"https://example.com/photo.jpg"}"#.utf8)
+ )) {
+ self.response = response
+ super.init(httpClient: InertHTTPClient(), siteApiRoot: URL(string: "https://example.com/wp-json/")!)
+ }
+
+ override func upload(fileURL: URL, mimeType: String, filename: String, fields: [MediaUploadField], query: String) async throws -> MediaUploadResponse {
+ let contents = (try? Data(contentsOf: fileURL)) ?? Data()
+ lock.withLock {
+ _uploads.append(Upload(fileURL: fileURL, contents: contents, mimeType: mimeType, filename: filename, fields: fields, query: query))
+ }
+ return response
+ }
+
+ override func deleteMedia(attachmentId: String, query: String) async throws -> MediaUploadResponse {
+ lock.withLock { _deletes.append((attachmentId, query)) }
+ return MediaUploadResponse(statusCode: 200, body: Data(#"{"deleted":true}"#.utf8))
+ }
+}
+
+// MARK: - Uploaders
+
+/// Records the ``MediaUpload`` it is handed, and returns a finished attachment.
+final class RecordingUploader: MediaUploader, @unchecked Sendable {
+ private let lock = NSLock()
+ private var _received: MediaUpload?
+ private var _receivedContents: Data?
+
+ var received: MediaUpload? { lock.withLock { _received } }
+ var receivedContents: Data? { lock.withLock { _receivedContents } }
+
+ func upload(_ upload: MediaUpload) async throws -> Data {
+ let contents = try? Data(contentsOf: upload.fileURL)
+ lock.withLock {
+ _received = upload
+ _receivedContents = contents
+ }
+ // Shaped like a real attachment: the editor's `transformAttachment` reads
+ // `title.raw`, so an example without it would model a body that fails in the
+ // editor.
+ return Data(#"{"id":7,"source_url":"https://example.com/photo.jpg","media_type":"image","title":{"raw":"photo"},"caption":{"raw":""}}"#.utf8)
+ }
+}
+
+/// An uploader whose delivery fails terminally, as one would after exhausting its own
+/// post-process recovery and force-deleting the orphan.
+struct ThrowingUploader: MediaUploader {
+ struct Failure: Error {}
+
+ func upload(_ upload: MediaUpload) async throws -> Data {
+ throw Failure()
+ }
+}
+
+// MARK: - Processors
+
+/// Records that `processFile` ran, and leaves the file as it is.
+final class ProcessOnlyProcessor: MediaProcessor, @unchecked Sendable {
+ private let lock = NSLock()
+ private var _processFileCalled = false
+
+ var processFileCalled: Bool { lock.withLock { _processFileCalled } }
+
+ func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile {
+ lock.withLock { _processFileCalled = true }
+ return .original
+ }
+}
+
+/// A processor that declines every file by metadata via `handlesFile`, and records
+/// whether `processFile` ran anyway.
+final class DeclineByMetadataProcessor: MediaProcessor, @unchecked Sendable {
+ private let lock = NSLock()
+ private var _processFileCalled = false
+
+ var processFileCalled: Bool { lock.withLock { _processFileCalled } }
+
+ func handlesFile(ofType mimeType: String, named filename: String) -> Bool { false }
+
+ func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile {
+ lock.withLock { _processFileCalled = true }
+ return .original
+ }
+}
+
+/// A value-type processor that transcodes into a new file. `struct`, and `Sendable`
+/// without `@unchecked` — the shape ``MediaProcessor``'s documentation recommends.
+struct ValueTypeProcessor: MediaProcessor {
+ func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile {
+ let processed = FileManager.default.temporaryDirectory
+ .appending(component: "value-\(UUID().uuidString).mp4")
+ try Data("transcoded".utf8).write(to: processed)
+ return .processed(processed, mimeType: "video/mp4", filename: "clip.mp4")
+ }
+}
+
+/// A processor that writes a new file with changed metadata, and remembers where.
+final class ResizingProcessor: MediaProcessor, @unchecked Sendable {
+ private let lock = NSLock()
+ private var _producedURL: URL?
+
+ /// The URL of the processed file this processor wrote, for cleanup assertions.
+ var producedURL: URL? { lock.withLock { _producedURL } }
+
+ func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile {
+ let newURL = FileManager.default.temporaryDirectory.appending(component: "processed-\(UUID().uuidString)")
+ try Data("processed".utf8).write(to: newURL)
+ lock.withLock { _producedURL = newURL }
+ return .processed(newURL, mimeType: "video/mp4", filename: "clip.mp4")
+ }
+}
+
+// MARK: - Upload service
+
+/// A ``MediaUploading`` service driven by closures, recording each call.
+final class ScriptedUploadService: MediaUploading, @unchecked Sendable {
+ struct Call: Equatable {
+ let file: MediaUploadFile
+ let contents: Data
+ let fields: [MediaUploadField]
+ let query: String
+ }
+
+ private let lock = NSLock()
+ private var _calls: [Call] = []
+ private var _deletes: [(String, String)] = []
+ private let onUpload: @Sendable (MediaUploadFile) async throws -> MediaUploadResponse
+
+ var calls: [Call] { lock.withLock { _calls } }
+ var deletes: [(String, String)] { lock.withLock { _deletes } }
+
+ init(onUpload: @escaping @Sendable (MediaUploadFile) async throws -> MediaUploadResponse = { _ in
+ MediaUploadResponse(statusCode: 201, body: Data(#"{"id":42}"#.utf8))
+ }) {
+ self.onUpload = onUpload
+ }
+
+ func upload(_ file: MediaUploadFile, fields: [MediaUploadField], query: String) async throws -> MediaUploadResponse {
+ let contents = (try? Data(contentsOf: file.url)) ?? Data()
+ lock.withLock { _calls.append(Call(file: file, contents: contents, fields: fields, query: query)) }
+ return try await onUpload(file)
+ }
+
+ func delete(attachmentId: String, query: String) async throws -> MediaUploadResponse {
+ lock.withLock { _deletes.append((attachmentId, query)) }
+ return MediaUploadResponse(statusCode: 200, body: Data(#"{"deleted":true}"#.utf8))
+ }
+}
+
+// MARK: - Files
+
+/// Writes `contents` to a new temporary file.
+func makeTemporaryFile(_ contents: Data = Data("fake image".utf8), named name: String = "photo.jpg") throws -> URL {
+ let directory = FileManager.default.temporaryDirectory.appending(component: "upload-tests-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let url = directory.appending(component: name)
+ try contents.write(to: url)
+ return url
+}
diff --git a/ios/Tests/GutenbergKitTests/Media/NativeFileInputTests.swift b/ios/Tests/GutenbergKitTests/Media/NativeFileInputTests.swift
new file mode 100644
index 000000000..e2d9f721c
--- /dev/null
+++ b/ios/Tests/GutenbergKitTests/Media/NativeFileInputTests.swift
@@ -0,0 +1,108 @@
+import Foundation
+import Testing
+import WebKit
+
+@testable import GutenbergKit
+
+@MainActor
+@Suite("NativeFileInput", .enabled(if: NativeFileInput.isSupported))
+struct NativeFileInputTests {
+ private let files = [URL(fileURLWithPath: "/tmp/IMG_0001.MOV"), URL(fileURLWithPath: "/tmp/IMG_0002.HEIC")]
+
+ @Test("is the web view's UI delegate only while files are on offer")
+ func offersForTheOperationOnly() async {
+ let input = NativeFileInput()
+ let webView = WKWebView()
+
+ let duringOffer = await input.offer(files, to: webView) {
+ (delegate: webView.uiDelegate === input, hasOffer: input.hasOffer)
+ }
+
+ #expect(duringOffer.delegate)
+ #expect(duringOffer.hasOffer)
+ #expect(webView.uiDelegate == nil)
+ #expect(!input.hasOffer)
+ }
+
+ @Test("gives the offer to the first file input that asks, in order, and to no other")
+ func offerIsTakenOnce() async {
+ let input = NativeFileInput()
+ let webView = WKWebView()
+
+ let (first, second) = await input.offer(files, to: webView) {
+ (input.takeOffer(), input.takeOffer())
+ }
+
+ #expect(first == files)
+ #expect(second == nil)
+ }
+
+ @Test("puts the host's own UI delegate back")
+ func restoresTheHostDelegate() async {
+ let input = NativeFileInput()
+ let webView = WKWebView()
+ let host = HostDelegate()
+ webView.uiDelegate = host
+
+ await input.offer(files, to: webView) {}
+
+ #expect(webView.uiDelegate === host)
+ }
+
+ @Test("ends the offer when the operation throws")
+ func endsTheOfferOnFailure() async {
+ let input = NativeFileInput()
+ let webView = WKWebView()
+
+ await #expect(throws: CancellationError.self) {
+ try await input.offer(files, to: webView) { throw CancellationError() }
+ }
+
+ #expect(webView.uiDelegate == nil)
+ #expect(!input.hasOffer)
+ }
+
+ @Test("runs the operation without an offer when there is nothing to offer")
+ func nothingToOffer() async {
+ let input = NativeFileInput()
+ let webView = WKWebView()
+
+ let wasDelegate = await input.offer([], to: webView) { webView.uiDelegate === input }
+
+ #expect(!wasDelegate)
+ }
+
+ @Test("leaves an open offer alone when asked for a second one")
+ func oneOfferAtATime() async {
+ let input = NativeFileInput()
+ let webView = WKWebView()
+ let other = [URL(fileURLWithPath: "/tmp/other.jpg")]
+
+ let taken = await input.offer(files, to: webView) {
+ await input.offer(other, to: webView) {}
+ return (delegate: webView.uiDelegate === input, files: input.takeOffer())
+ }
+
+ #expect(taken.delegate, "the second offer ended the first")
+ #expect(taken.files == files)
+ }
+}
+
+@MainActor
+@Suite("NativeFileInput before iOS 18.4", .enabled(if: !NativeFileInput.isSupported))
+struct UnsupportedNativeFileInputTests {
+ @Test("runs the operation without an offer, and leaves the web view's delegate alone")
+ func makesNoOffer() async {
+ let input = NativeFileInput()
+ let webView = WKWebView()
+
+ let during = await input.offer([URL(fileURLWithPath: "/tmp/IMG_0001.MOV")], to: webView) {
+ (delegate: webView.uiDelegate === input, hasOffer: input.hasOffer)
+ }
+
+ #expect(!during.delegate)
+ #expect(!during.hasOffer)
+ }
+}
+
+private final class HostDelegate: NSObject, WKUIDelegate {}
diff --git a/ios/Tests/GutenbergKitTests/Media/RestRelayHandleTests.swift b/ios/Tests/GutenbergKitTests/Media/RestRelayHandleTests.swift
new file mode 100644
index 000000000..81afdd784
--- /dev/null
+++ b/ios/Tests/GutenbergKitTests/Media/RestRelayHandleTests.swift
@@ -0,0 +1,231 @@
+import Foundation
+import Testing
+@testable import GutenbergKit
+
+/// Covers what ``RestRelay/handle(_:)`` sends upstream and what it hands back,
+/// against a stubbed session rather than a site.
+///
+/// The header rewriting on both sides of the hop is the part of the relay that
+/// fails silently: an upstream `Access-Control-Allow-Origin` that survives is
+/// honored by WebKit over the policy's own, and a surviving `Content-Encoding`
+/// makes WebKit decode an already-decoded body.
+@Suite("RestRelay request handling")
+struct RestRelayHandleTests {
+
+ // MARK: - Request rewriting
+
+ @Test("injects the site credential and discards the caller's")
+ func injectsSiteCredential() async throws {
+ let exchange = try await relay(
+ headers: ["Authorization": "Bearer caller-token"]
+ )
+
+ #expect(exchange.upstream.value(forHTTPHeaderField: "Authorization") == "Bearer test-token")
+ }
+
+ @Test("strips the headers that describe the web view's own hop")
+ func stripsHopHeaders() async throws {
+ // `origin`/`referer`/`sec-fetch-*` describe the `file://` page and are
+ // what WordPress rejects in the first place; the page's cookies are
+ // not how the relay authenticates; the rest belong to the hop.
+ let exchange = try await relay(headers: [
+ "Origin": "file://",
+ "Referer": "file:///editor.html",
+ "Sec-Fetch-Site": "cross-site",
+ "Sec-Fetch-Mode": "cors",
+ "Cookie": "wordpress_logged_in_abc=1",
+ "Connection": "keep-alive",
+ "Accept-Encoding": "gzip, deflate",
+ ])
+
+ for header in [
+ "Origin", "Referer", "Sec-Fetch-Site", "Sec-Fetch-Mode",
+ "Cookie", "Connection",
+ ] {
+ #expect(
+ exchange.upstream.value(forHTTPHeaderField: header) == nil,
+ "\(header) should not reach the site"
+ )
+ }
+ }
+
+ @Test("forwards the headers the site needs")
+ func forwardsContentHeaders() async throws {
+ let exchange = try await relay(
+ method: "POST",
+ headers: ["Content-Type": "application/json", "X-HTTP-Method-Override": "PUT"]
+ )
+
+ #expect(exchange.upstream.value(forHTTPHeaderField: "Content-Type") == "application/json")
+ #expect(exchange.upstream.value(forHTTPHeaderField: "X-HTTP-Method-Override") == "PUT")
+ #expect(exchange.upstream.httpMethod == "POST")
+ }
+
+ // MARK: - Response rewriting
+
+ @Test("strips the upstream CORS headers that would override the policy's")
+ func stripsUpstreamCORSHeaders() async throws {
+ // WordPress answers an origin it rejects with an *empty*
+ // `Access-Control-Allow-Origin`. One that survived would replace the
+ // relay's own, and WebKit would reject the response the relay exists
+ // to deliver.
+ let exchange = try await relay(responseHeaders: [
+ "Access-Control-Allow-Origin": "",
+ "Access-Control-Allow-Credentials": "true",
+ "Access-Control-Expose-Headers": "X-Upstream",
+ "Vary": "Origin",
+ ])
+
+ for header in ["Access-Control-Allow-Credentials", "Vary"] {
+ #expect(
+ exchange.response.header(header) == nil,
+ "\(header) should not reach the web view"
+ )
+ }
+ // The relay's own headers replace the upstream's, rather than both
+ // arriving and the browser reading whichever came first.
+ #expect(exchange.response.header("Access-Control-Allow-Origin") == "*")
+ let exposed = exchange.response.headers.filter { $0.key.lowercased() == "access-control-expose-headers" }
+ #expect(exposed.count == 1)
+ #expect(exposed.first?.value.hasPrefix("*, Allow") == true)
+ }
+
+ @Test("strips the site's cookies rather than rescoping them to the relay")
+ func stripsSetCookie() async throws {
+ // Passed on, these would be stored against the relay's scheme, a
+ // different origin from the site that set them.
+ let exchange = try await relay(responseHeaders: [
+ "Set-Cookie": "wordpress_logged_in_abc=user%7C123; Path=/; HttpOnly",
+ ])
+
+ #expect(exchange.response.header("Set-Cookie") == nil)
+ }
+
+ @Test("strips Content-Encoding and Content-Length, which URLSession already acted on")
+ func stripsContentEncoding() async throws {
+ // `URLSession` decompresses transparently, so advertising the upstream
+ // encoding makes WebKit decode plain bytes a second time, and the
+ // upstream length is the compressed one.
+ let exchange = try await relay(responseHeaders: ["Content-Encoding": "gzip", "Content-Length": "12"])
+
+ #expect(exchange.response.header("Content-Encoding") == nil)
+ #expect(exchange.response.header("Content-Length") == nil)
+ }
+
+ @Test("sends the page's body on, and exposes a block's own response headers")
+ func relaysBodiesAndPluginHeaders() async throws {
+ // A VideoPress chunk: the page read its `Blob` into an `ArrayBuffer`,
+ // which WebKit delivers as `httpBody`, and the block reads the video
+ // it made off the response headers.
+ let exchange = try await relay(
+ target: "/proxy/videopress/v1/upload-relay/abc",
+ method: "POST",
+ headers: ["Content-Type": "application/offset+octet-stream", "Upload-Offset": "0"],
+ body: Data("chunk bytes".utf8),
+ status: 204,
+ responseHeaders: ["x-videopress-upload-guid": "eDeLfBNN"]
+ )
+
+ #expect(exchange.upstream.url?.absoluteString == "https://example.com/wp-json/videopress/v1/upload-relay/abc")
+ #expect(exchange.sentBody == Data("chunk bytes".utf8))
+ #expect(exchange.upstream.value(forHTTPHeaderField: "Upload-Offset") == "0")
+ #expect(exchange.response.status == 204)
+ #expect(exchange.response.header("x-videopress-upload-guid") == "eDeLfBNN")
+ #expect(exchange.response.header("Access-Control-Expose-Headers")?.hasPrefix("*") == true)
+ }
+
+ @Test("relays the status, body, and the headers the editor reads")
+ func relaysStatusBodyAndHeaders() async throws {
+ let exchange = try await relay(
+ status: 201,
+ responseHeaders: ["Allow": "GET, POST", "X-WP-Total": "42"],
+ responseBody: Data(#"{"id":1}"#.utf8)
+ )
+
+ #expect(exchange.response.status == 201)
+ #expect(exchange.response.body == Data(#"{"id":1}"#.utf8))
+ #expect(exchange.response.header("Allow") == "GET, POST")
+ #expect(exchange.response.header("X-WP-Total") == "42")
+ }
+
+ @Test("answers an upstream failure as a relay error the editor can decode")
+ func reportsUpstreamFailure() async throws {
+ let exchange = try await relay(failure: URLError(.notConnectedToInternet))
+
+ #expect(exchange.response.status == 502)
+ let body = try #require(String(data: exchange.response.body, encoding: .utf8))
+ #expect(body.contains("relay_upstream_failed"))
+ }
+
+ @Test("refuses a path outside the API root before sending anything")
+ func refusesForbiddenPath() async throws {
+ let exchange = try await relay(target: "/proxy/../wp-admin/")
+
+ #expect(exchange.response.status == 403)
+ #expect(exchange.sentRequest == nil, "nothing should have been sent upstream")
+ }
+
+ // MARK: - Helpers
+
+ /// One relayed exchange: what reached the stub, and what the relay returned.
+ private struct Exchange {
+ let sentRequest: URLRequest?
+ let sentBody: Data?
+ let response: SchemeResponse
+
+ /// The request that reached the stub. Fails the test if none did.
+ var upstream: URLRequest {
+ guard let sentRequest else {
+ Issue.record("No request reached the stubbed session")
+ return URLRequest(url: URL(string: "about:blank")!)
+ }
+ return sentRequest
+ }
+ }
+
+ /// Relays one request through a stubbed session and reports both sides.
+ private func relay(
+ target: String = "/proxy/wp/v2/posts?_locale=user",
+ method: String = "GET",
+ headers: [String: String] = [:],
+ body: Data? = nil,
+ status: Int = 200,
+ responseHeaders: [String: String] = [:],
+ responseBody: Data = Data(),
+ failure: (any Error)? = nil
+ ) async throws -> Exchange {
+ let stub = StubURLProtocol.Stub(
+ status: status,
+ headers: responseHeaders,
+ body: responseBody,
+ failure: failure
+ )
+ let stubbed = StubURLProtocol.makeSession(stub: stub)
+ defer { stubbed.finish() }
+
+ let relay = RestRelay(
+ configuration: EditorConfigurationBuilder(
+ postType: .post,
+ siteURL: URL(string: "https://example.com")!,
+ siteApiRoot: URL(string: "https://example.com/wp-json/")!,
+ authHeader: "Bearer test-token"
+ ).build(),
+ session: stubbed.session
+ )
+
+ var request = URLRequest(url: try #require(URL(string: "gbk-rest://relay\(target)")))
+ request.httpMethod = method
+ request.allHTTPHeaderFields = headers
+ request.httpBody = body
+ let response = await relay.handle(request)
+
+ return Exchange(sentRequest: stubbed.recorder.request, sentBody: stubbed.recorder.request?.httpBody, response: response)
+ }
+}
+
+private extension SchemeResponse {
+ /// The value of the first header matching `name`, case-insensitively.
+ func header(_ name: String) -> String? {
+ headers.first { $0.key.lowercased() == name.lowercased() }?.value
+ }
+}
diff --git a/ios/Tests/GutenbergKitTests/Media/RestRelayTests.swift b/ios/Tests/GutenbergKitTests/Media/RestRelayTests.swift
new file mode 100644
index 000000000..73a1b9f8d
--- /dev/null
+++ b/ios/Tests/GutenbergKitTests/Media/RestRelayTests.swift
@@ -0,0 +1,317 @@
+import Foundation
+import Testing
+@testable import GutenbergKit
+
+/// Covers how a relayed request's path becomes an upstream URL. This is the
+/// relay's containment boundary: the web view supplies a path, never a URL, and
+/// nothing it can put in that path may address anything outside the site API
+/// root.
+@Suite("RestRelay upstream URL")
+struct RestRelayTests {
+
+ /// A site on pretty permalinks.
+ private static let prettyRoot = URL(string: "https://example.com/wp-json/")!
+
+ /// A site on plain permalinks, where the API root carries a query and the
+ /// path has to merge into it rather than start a second one.
+ private static let plainRoot = URL(string: "https://example.com/?rest_route=/")!
+
+ // MARK: - Path resolution
+
+ @Test("appends the path and query to the API root")
+ func appendsPathAndQuery() {
+ let relay = makeRelay(apiRoot: Self.prettyRoot)
+ #expect(
+ relay.upstreamURL(for: request("/proxy/wp/v2/posts?_locale=user"))?.absoluteString
+ == "https://example.com/wp-json/wp/v2/posts?_locale=user"
+ )
+ }
+
+ @Test("resolves the API root itself for a bare route")
+ func bareRouteResolvesToAPIRoot() {
+ let relay = makeRelay(apiRoot: Self.prettyRoot)
+ #expect(relay.upstreamURL(for: request("/proxy"))?.absoluteString == "https://example.com/wp-json/")
+ #expect(relay.upstreamURL(for: request("/proxy/"))?.absoluteString == "https://example.com/wp-json/")
+ }
+
+ @Test("adds a trailing slash to an API root configured without one")
+ func normalizesAPIRootWithoutTrailingSlash() {
+ let relay = makeRelay(apiRoot: URL(string: "https://example.com/wp-json")!)
+ #expect(
+ relay.upstreamURL(for: request("/proxy/wp/v2/posts"))?.absoluteString
+ == "https://example.com/wp-json/wp/v2/posts"
+ )
+ }
+
+ @Test("continues the query of an API root that already carries one")
+ func mergesIntoAQueryCarryingAPIRoot() {
+ // Plain permalinks: `https://example.com/?rest_route=/` + `wp/v2/posts`
+ // has to produce one query string, not two — mirroring what
+ // `createRootURLMiddleware` does on the JavaScript side.
+ let relay = makeRelay(apiRoot: Self.plainRoot)
+ #expect(
+ relay.upstreamURL(for: request("/proxy/wp/v2/posts?_locale=user"))?.absoluteString
+ == "https://example.com/?rest_route=/wp/v2/posts&_locale=user"
+ )
+ #expect(
+ relay.upstreamURL(for: request("/proxy/wp/v2/posts?a=1&b=2"))?.absoluteString
+ == "https://example.com/?rest_route=/wp/v2/posts&a=1&b=2"
+ )
+ }
+
+ @Test("decodes the route of a root advertised with it encoded")
+ func decodesAdvertisedPlainPermalinkRoot() {
+ // WordPress advertises a plain-permalink root through `add_query_arg`,
+ // which percent-encodes the route: `index.php?rest_route=%2F`. The
+ // slash termination has to land inside that value, since WordPress
+ // reads `rest_route=%2F/wp/v2/posts` as the route `//wp/v2/posts`.
+ let relay = makeRelay(apiRoot: URL(string: "https://example.com/index.php?rest_route=%2F")!)
+ #expect(
+ relay.upstreamURL(for: request("/proxy/wp/v2/posts?_locale=user"))?.absoluteString
+ == "https://example.com/index.php?rest_route=/wp/v2/posts&_locale=user"
+ )
+ #expect(
+ relay.upstreamURL(for: request("/proxy"))?.absoluteString
+ == "https://example.com/index.php?rest_route=/"
+ )
+ }
+
+ @Test("preserves percent-encoding in the query")
+ func preservesPercentEncoding() {
+ let relay = makeRelay(apiRoot: Self.prettyRoot)
+ #expect(
+ relay.upstreamURL(for: request("/proxy/wp/v2/search?search=caf%C3%A9&per_page=100"))?.absoluteString
+ == "https://example.com/wp-json/wp/v2/search?search=caf%C3%A9&per_page=100"
+ )
+ }
+
+ // MARK: - Containment
+
+ @Test("refuses a path that walks out of the API root")
+ func refusesDotSegments() {
+ let relay = makeRelay(apiRoot: Self.prettyRoot)
+ #expect(relay.upstreamURL(for: request("/proxy/../wp-admin/admin-ajax.php")) == nil)
+ #expect(relay.upstreamURL(for: request("/proxy/wp/v2/../../../wp-admin/")) == nil)
+ #expect(relay.upstreamURL(for: request("/proxy/wp/v2/./posts")) == nil)
+ }
+
+ @Test("refuses percent-encoded dot segments")
+ func refusesEncodedDotSegments() {
+ // `URLSession` leaves these encoded, but the receiving server may decode
+ // before resolving, so they are refused here rather than forwarded.
+ let relay = makeRelay(apiRoot: Self.prettyRoot)
+ #expect(relay.upstreamURL(for: request("/proxy/%2e%2e/wp-admin/")) == nil)
+ #expect(relay.upstreamURL(for: request("/proxy/wp/%2E%2E/%2e%2e/")) == nil)
+ }
+
+ @Test("refuses dot segments whose separators are encoded too")
+ func refusesDotSegmentsWithEncodedSeparators() {
+ // The whole traversal is one literal segment, so it is only a dot
+ // segment to a server that decodes the separator before normalizing.
+ let relay = makeRelay(apiRoot: Self.prettyRoot)
+ #expect(relay.upstreamURL(for: request("/proxy/%2e%2e%2f%2e%2e%2fwp-admin/admin-ajax.php")) == nil)
+ #expect(relay.upstreamURL(for: request("/proxy/wp%5C..%5Cwp-admin/")) == nil)
+ #expect(relay.upstreamURL(for: request("/proxy/wp\\..\\wp-admin/")) == nil)
+ }
+
+ @Test("an encoded slash within a segment is not a dot segment")
+ func allowsEncodedSlashesWithinSegments() {
+ // A template ID is `theme//slug`, encoded into a single path segment.
+ let relay = makeRelay(apiRoot: Self.prettyRoot)
+ #expect(
+ relay.upstreamURL(for: request("/proxy/wp/v2/templates/twentytwentyfour%2F%2Fsingle"))?.absoluteString
+ == "https://example.com/wp-json/wp/v2/templates/twentytwentyfour%2F%2Fsingle"
+ )
+ }
+
+ @Test("a dot inside a path segment is not a dot segment")
+ func allowsDotsWithinSegments() {
+ let relay = makeRelay(apiRoot: Self.prettyRoot)
+ #expect(
+ relay.upstreamURL(for: request("/proxy/oembed/1.0/embed?url=https%3A%2F%2Fexample.com"))?.absoluteString
+ == "https://example.com/wp-json/oembed/1.0/embed?url=https%3A%2F%2Fexample.com"
+ )
+ }
+
+ @Test("an absolute URL in the path stays under the API root")
+ func absoluteURLInPathStaysContained() {
+ // There is no URL to resolve, so a smuggled one becomes an ordinary
+ // (404ing) path segment rather than another host.
+ let relay = makeRelay(apiRoot: Self.prettyRoot)
+ let resolved = relay.upstreamURL(for: request("/proxy/https://elsewhere.example/x"))
+ #expect(resolved?.absoluteString.hasPrefix("https://example.com/wp-json/") == true)
+ #expect(relay.upstreamURL(for: request("/proxy//elsewhere.example/x"))?.host() == "example.com")
+ }
+
+ @Test("forwards the query verbatim, rest_route included")
+ func forwardsQueryVerbatim() {
+ // WordPress prefers `$_GET['rest_route']` over the route the path
+ // names, so the caller picks the route whichever way it spells the
+ // parameter. Deliberately not filtered: see the type's Security notes.
+ let relay = makeRelay(apiRoot: Self.prettyRoot)
+ #expect(
+ relay.upstreamURL(for: request("/proxy/wp/v2/posts?rest_route=/wp/v2/users&_locale=user"))?.absoluteString
+ == "https://example.com/wp-json/wp/v2/posts?rest_route=/wp/v2/users&_locale=user"
+ )
+ }
+
+ @Test("refuses a request outside the relay route")
+ func refusesForeignRoute() {
+ let relay = makeRelay(apiRoot: Self.prettyRoot)
+ #expect(relay.upstreamURL(for: request("/sessions")) == nil)
+ #expect(relay.upstreamURL(for: request("/proxying/wp/v2/posts")) == nil)
+ }
+
+ // MARK: - Redirects
+
+ @Test("follows a redirect that stays under the API root")
+ func followsContainedRedirect() {
+ let followed = redirectDecision(to: "https://example.com/wp-json/wp/v2/posts/1")
+
+ #expect(followed?.url?.absoluteString == "https://example.com/wp-json/wp/v2/posts/1")
+ }
+
+ @Test("refuses a redirect that leaves the API root")
+ func refusesEscapingRedirect() {
+ // Another host, another path on the same site, and a scheme downgrade.
+ // The credential should follow the request only to the API it was
+ // configured for.
+ for target in [
+ "https://elsewhere.example/wp-json/wp/v2/posts",
+ "https://example.com/wp-login.php",
+ "http://example.com/wp-json/wp/v2/posts",
+ ] {
+ #expect(redirectDecision(to: target) == nil, "should refuse \(target)")
+ }
+ }
+
+ @Test("follows a redirect that only upgrades the scheme")
+ func followsSchemeUpgrade() {
+ // A site whose `siteurl` is `http` but which answers on `https` is the
+ // deployment `relayUpstreamPath` already relays; refusing its redirect
+ // here would fail every request the layer above deliberately sent.
+ let redirectGuard = RestRelay.RedirectGuard(allowedPrefix: "http://example.com/wp-json/")
+ let followed = decide(redirectGuard, target: "https://example.com/wp-json/wp/v2/posts")
+
+ #expect(followed?.url?.absoluteString == "https://example.com/wp-json/wp/v2/posts")
+ #expect(redirectGuard.refusedTarget == nil)
+ }
+
+ @Test("the scheme upgrade admits nothing else")
+ func schemeUpgradeStaysContained() {
+ // Upgrading the scheme must not also relax the host or the path.
+ for target in [
+ "https://elsewhere.example/wp-json/wp/v2/posts",
+ "https://example.com/wp-login.php",
+ "https://example.com:8443/wp-json/wp/v2/posts",
+ ] {
+ let redirectGuard = RestRelay.RedirectGuard(allowedPrefix: "http://example.com/wp-json/")
+ #expect(decide(redirectGuard, target: target) == nil, "should refuse \(target)")
+ }
+ }
+
+ @Test("follows a redirect to an alias of the configured host")
+ func followsHostAlias() {
+ // The spellings `relayUpstreamPath` tolerates, so the two layers
+ // agree: a site whose canonical redirect names its `www.` alias, or
+ // wp-env answering on `127.0.0.1` for a root configured as
+ // `localhost`, would otherwise have every relayed request refused.
+ for (root, target) in [
+ ("https://example.com/wp-json/", "https://www.example.com/wp-json/wp/v2/posts"),
+ ("https://www.example.com/wp-json/", "https://example.com/wp-json/wp/v2/posts"),
+ ("http://localhost:8888/wp-json/", "http://127.0.0.1:8888/wp-json/wp/v2/posts"),
+ ("http://example.com/wp-json/", "https://www.example.com/wp-json/wp/v2/posts"),
+ ("https://example.com/wp-json/", "https://Example.com:443/wp-json/wp/v2/posts"),
+ ] {
+ let redirectGuard = RestRelay.RedirectGuard(allowedPrefix: root)
+ #expect(decide(redirectGuard, target: target)?.url?.absoluteString == target, "should follow \(target)")
+ #expect(redirectGuard.refusedTarget == nil)
+ }
+ }
+
+ @Test("the alias tolerance admits nothing else")
+ func hostAliasStaysContained() {
+ // Only the spellings of the same name: not a subdomain, a lookalike,
+ // another port on the alias, or another path.
+ for target in [
+ "https://www2.example.com/wp-json/wp/v2/posts",
+ "https://example.com.evil.example/wp-json/wp/v2/posts",
+ "https://wwwexample.com/wp-json/wp/v2/posts",
+ "https://www.example.com:8443/wp-json/wp/v2/posts",
+ "https://www.example.com/wp-login.php",
+ ] {
+ #expect(redirectDecision(to: target) == nil, "should refuse \(target)")
+ }
+ }
+
+ @Test("reports the refused target so the editor can say what happened")
+ func recordsRefusedTarget() {
+ // Without this the relay would hand back the 3xx itself, and `fetch`
+ // — which follows redirects by default — would chase it to the host
+ // the guard just declined.
+ let redirectGuard = RestRelay.RedirectGuard(allowedPrefix: "https://example.com/wp-json/")
+ #expect(redirectGuard.refusedTarget == nil)
+
+ _ = decide(redirectGuard, target: "https://elsewhere.example/x")
+
+ #expect(redirectGuard.refusedTarget == "https://elsewhere.example/x")
+ }
+
+ // MARK: - Helpers
+
+ private func makeRelay(apiRoot: URL) -> RestRelay {
+ RestRelay(
+ configuration: EditorConfigurationBuilder(
+ postType: .post,
+ siteURL: URL(string: "https://example.com")!,
+ siteApiRoot: apiRoot,
+ authHeader: "Bearer test-token"
+ ).build()
+ )
+ }
+
+ /// The URL the page's `fetch` addresses for a relay target such as
+ /// `/proxy/wp/v2/posts?x=1`.
+ private func request(_ target: String) -> URL {
+ URL(string: "gbk-rest://relay\(target)")!
+ }
+
+ /// The request a fresh guard would follow for a redirect to `target`, or
+ /// `nil` if it refuses.
+ private func redirectDecision(to target: String) -> URLRequest? {
+ decide(
+ RestRelay.RedirectGuard(allowedPrefix: "https://example.com/wp-json/"),
+ target: target
+ )
+ }
+
+ /// A task for the delegate signature, which the guard never inspects.
+ ///
+ /// Built once from a session the tests own and immediately invalidated:
+ /// asking `URLSession.shared` for one per assertion left a suspended task
+ /// retained for the life of the test process each time.
+ private static let unusedTask: URLSessionTask = {
+ let session = URLSession(configuration: .ephemeral)
+ defer { session.invalidateAndCancel() }
+ return session.dataTask(with: URL(string: "https://example.com/")!)
+ }()
+
+ /// Asks `redirectGuard` whether to follow a redirect to `target`.
+ private func decide(_ redirectGuard: RestRelay.RedirectGuard, target: String) -> URLRequest? {
+ let url = URL(string: target)!
+ var followed: URLRequest?
+ redirectGuard.urlSession(
+ .shared,
+ task: Self.unusedTask,
+ willPerformHTTPRedirection: HTTPURLResponse(
+ url: URL(string: "https://example.com/wp-json/wp/v2/posts")!,
+ statusCode: 301,
+ httpVersion: "HTTP/1.1",
+ headerFields: ["Location": target]
+ )!,
+ newRequest: URLRequest(url: url),
+ completionHandler: { followed = $0 }
+ )
+ return followed
+ }
+}
diff --git a/ios/Tests/GutenbergKitTests/Media/StubURLProtocol.swift b/ios/Tests/GutenbergKitTests/Media/StubURLProtocol.swift
new file mode 100644
index 000000000..9406a436c
--- /dev/null
+++ b/ios/Tests/GutenbergKitTests/Media/StubURLProtocol.swift
@@ -0,0 +1,146 @@
+import Foundation
+
+/// A `URLProtocol` that answers from a canned response and records the request
+/// it was asked to send, so a `URLSession` client can be tested without a
+/// server.
+///
+/// The stub and the recorder are held per-session rather than in a global: two
+/// tests running in parallel each build their own session, and a shared slot
+/// would hand one test the other's request.
+final class StubURLProtocol: URLProtocol {
+
+ /// The canned response one session answers with.
+ struct Stub: Sendable {
+ var status: Int = 200
+ var headers: [String: String] = [:]
+ var body = Data()
+ /// When set, the request fails with this error instead of responding.
+ var failure: (any Error)?
+ }
+
+ /// The request that reached the stub, if any.
+ final class Recorder: @unchecked Sendable {
+ private let lock = NSLock()
+ private var _request: URLRequest?
+
+ var request: URLRequest? {
+ lock.withLock { _request }
+ }
+
+ func record(_ request: URLRequest) {
+ lock.withLock { _request = request }
+ }
+ }
+
+ /// Per-session configuration, keyed by a token carried in the session's
+ /// `httpAdditionalHeaders` — the only channel a `URLProtocol` subclass has
+ /// to the session that instantiated it.
+ private struct Registration {
+ let stub: Stub
+ let recorder: Recorder
+ }
+
+ private static let lock = NSLock()
+ nonisolated(unsafe) private static var registrations: [String: Registration] = [:]
+
+ private static let tokenHeader = "X-Stub-Session"
+
+ /// A stubbed session and the recorder capturing what it was asked to send.
+ ///
+ /// Call ``finish()`` when the test is done. Invalidating the session alone
+ /// would leave the registration — and the stub's body — held for the life
+ /// of the test process.
+ struct Stubbed {
+ let session: URLSession
+ let recorder: Recorder
+ private let token: String
+
+ init(session: URLSession, recorder: Recorder, token: String) {
+ self.session = session
+ self.recorder = recorder
+ self.token = token
+ }
+
+ func finish() {
+ session.invalidateAndCancel()
+ StubURLProtocol.lock.withLock {
+ StubURLProtocol.registrations[token] = nil
+ }
+ }
+ }
+
+ /// A session that answers every request with `stub`.
+ static func makeSession(stub: Stub) -> Stubbed {
+ let token = UUID().uuidString
+ let recorder = Recorder()
+
+ lock.withLock {
+ registrations[token] = Registration(stub: stub, recorder: recorder)
+ }
+
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [StubURLProtocol.self]
+ configuration.httpAdditionalHeaders = [tokenHeader: token]
+ return Stubbed(
+ session: URLSession(configuration: configuration),
+ recorder: recorder,
+ token: token
+ )
+ }
+
+ override class func canInit(with request: URLRequest) -> Bool {
+ request.value(forHTTPHeaderField: tokenHeader) != nil
+ }
+
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ guard let token = request.value(forHTTPHeaderField: Self.tokenHeader),
+ let registration = Self.lock.withLock({ Self.registrations[token] }) else {
+ client?.urlProtocol(self, didFailWithError: URLError(.unsupportedURL))
+ return
+ }
+
+ // `URLProtocol` hands over the body as a stream once the request is
+ // built, so read it back for the recorded copy.
+ var recorded = request
+ recorded.setValue(nil, forHTTPHeaderField: Self.tokenHeader)
+ if recorded.httpBody == nil, let stream = recorded.httpBodyStream {
+ recorded.httpBody = Self.readAll(stream)
+ }
+ registration.recorder.record(recorded)
+
+ if let failure = registration.stub.failure {
+ client?.urlProtocol(self, didFailWithError: failure)
+ return
+ }
+
+ let response = HTTPURLResponse(
+ url: request.url!,
+ statusCode: registration.stub.status,
+ httpVersion: "HTTP/1.1",
+ headerFields: registration.stub.headers
+ )!
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: registration.stub.body)
+ client?.urlProtocolDidFinishLoading(self)
+ }
+
+ override func stopLoading() {}
+
+ private static func readAll(_ stream: InputStream) -> Data {
+ stream.open()
+ defer { stream.close() }
+
+ var data = Data()
+ var buffer = [UInt8](repeating: 0, count: 4096)
+ while stream.hasBytesAvailable {
+ let read = stream.read(&buffer, maxLength: buffer.count)
+ guard read > 0 else { break }
+ data.append(buffer, count: read)
+ }
+ return data
+ }
+}
diff --git a/ios/Tests/GutenbergKitTests/Model/JSONTests.swift b/ios/Tests/GutenbergKitTests/Model/JSONTests.swift
index ed99a5fb5..2a40752e3 100644
--- a/ios/Tests/GutenbergKitTests/Model/JSONTests.swift
+++ b/ios/Tests/GutenbergKitTests/Model/JSONTests.swift
@@ -819,6 +819,43 @@ struct JSONTests {
#expect(decodedFalse == originalFalse)
}
+ // MARK: - Description Tests
+
+ @Test("description is the value as JSON")
+ func descriptionIsJSON() throws {
+ let json: JSON = ["b": [1, 2.5, "three", true, nil], "a": ["url": "https://example.com/a"]]
+
+ #expect(try JSON(Data(json.description.utf8)) == json)
+ // Readable: one member to a line, in a stable order, with slashes left alone
+ #expect(json.description.contains("\n"))
+ #expect(json.description.contains("https://example.com/a"))
+ #expect(try #require(json.description.firstRange(of: #""a""#)).lowerBound < #require(json.description.firstRange(of: #""b""#)).lowerBound)
+ }
+
+ @Test("description handles a value that isn't an object or an array")
+ func descriptionHandlesFragments() {
+ #expect(JSON.string("a/b").description == #""a/b""#)
+ #expect(JSON.number(1.5).description == "1.5")
+ #expect(JSON.boolean(true).description == "true")
+ #expect(JSON.null.description == "null")
+ }
+
+ @Test("description handles a number that JSON can't represent")
+ func descriptionHandlesNonFiniteNumbers() {
+ #expect(JSON.number(.infinity).description == #""Infinity""#)
+ #expect(JSON.number(-.infinity).description == #""-Infinity""#)
+ #expect(JSON.number(.nan).description == #""NaN""#)
+ }
+
+ @Test("a value holding JSON can be described, as a failing expectation or a log message would")
+ func valuesHoldingJSONCanBeDescribed() throws {
+ let settings = try EditorSettings(data: Data(#"{"styles":[],"alignWide":true}"#.utf8))
+ let dependencies = EditorDependencies(editorSettings: settings, assetBundle: .empty, preloadList: nil)
+
+ #expect(String(describing: settings).contains("alignWide"))
+ #expect(String(reflecting: dependencies).contains("alignWide"))
+ }
+
// MARK: - Resource File Validation Tests
static let objectResourceFiles = [
diff --git a/ios/Tests/GutenbergKitTests/Services/EditorDependencyLoaderTests.swift b/ios/Tests/GutenbergKitTests/Services/EditorDependencyLoaderTests.swift
new file mode 100644
index 000000000..8e699dc0b
--- /dev/null
+++ b/ios/Tests/GutenbergKitTests/Services/EditorDependencyLoaderTests.swift
@@ -0,0 +1,96 @@
+import Foundation
+import Testing
+
+@testable import GutenbergKit
+
+/// The loader's contract with its owner: it reports back, and it never holds the owner —
+/// the property that lets a released editor go while its fetch is still in flight.
+@Suite("EditorDependencyLoader")
+struct EditorDependencyLoaderTests: MakesTestFixtures {
+ static let testSiteURL = URL(string: "https://example.com")!
+ static let testApiRoot = URL(string: "https://example.com/wp-json")!
+
+ @MainActor
+ @Test("delivers the dependencies it fetched")
+ func deliversTheDependencies() async throws {
+ // Offline mode resolves without a request, so the fetch succeeds.
+ let configuration = makeConfigurationBuilder().setIsOfflineModeEnabled(true).build()
+ let owner = LoaderOwner(service: makeService(for: configuration))
+
+ try await owner.waitUntilFinished()
+ #expect(owner.dependencies != nil)
+ #expect(owner.error == nil)
+ }
+
+ @MainActor
+ @Test("delivers the error when the fetch fails")
+ func deliversTheError() async throws {
+ let session = ParkedURLSession()
+ defer { session.release() }
+ let owner = LoaderOwner(service: makeService(session: session))
+ try await session.waitUntilStarted()
+
+ session.release() // fails every parked request
+ try await owner.waitUntilFinished()
+ #expect(owner.error != nil)
+ #expect(owner.dependencies == nil)
+ }
+
+ @MainActor
+ @Test("releasing its owner mid-fetch frees the owner, and leaves the fetch running")
+ func releasingTheOwnerMidFetchFreesIt() async throws {
+ let session = ParkedURLSession()
+ defer { session.release() }
+ var owner: LoaderOwner? = LoaderOwner(service: makeService(session: session))
+ weak let releasedOwner = owner
+ try await session.waitUntilStarted()
+
+ owner = nil
+ #expect(releasedOwner == nil, "the fetch should not hold its owner")
+
+ let cancelled = await session.waitUntilCancelled(timeout: .milliseconds(250))
+ #expect(!cancelled, "freeing the owner should not cancel the fetch")
+ }
+
+ /// A service whose every request lands in `session`, with storage no other test shares.
+ private func makeService(session: ParkedURLSession) -> EditorService {
+ let configuration = makeConfiguration()
+ return EditorService(
+ configuration: configuration,
+ httpClient: EditorHTTPClient(urlSession: session, authHeader: configuration.authHeader),
+ storageRoot: .randomTemporaryDirectory,
+ cacheRoot: .randomTemporaryDirectory
+ )
+ }
+}
+
+/// Stands in for `EditorViewController`: owns its loader, and records what it is told.
+@MainActor
+private final class LoaderOwner: EditorDependencyLoaderDelegate {
+ private var loader: EditorDependencyLoader?
+ private(set) var dependencies: EditorDependencies?
+ private(set) var error: (any Error)?
+
+ init(service: EditorService) {
+ loader = EditorDependencyLoader(service: service, delegate: self)
+ }
+
+ func dependencyLoader(_ loader: EditorDependencyLoader, didUpdate progress: EditorProgress) {}
+
+ func dependencyLoader(_ loader: EditorDependencyLoader, didLoad dependencies: EditorDependencies) {
+ self.dependencies = dependencies
+ }
+
+ func dependencyLoader(_ loader: EditorDependencyLoader, didFailWith error: any Error) {
+ self.error = error
+ }
+
+ func waitUntilFinished() async throws {
+ let clock = ContinuousClock()
+ let deadline = clock.now + .seconds(10)
+ while dependencies == nil && error == nil && clock.now < deadline {
+ try await Task.sleep(for: .milliseconds(20))
+ }
+ try #require(dependencies != nil || error != nil, "the loader never reported back")
+ }
+}
diff --git a/ios/Tests/GutenbergKitTests/Services/EditorServiceTests.swift b/ios/Tests/GutenbergKitTests/Services/EditorServiceTests.swift
index 9635ba5ca..a84e81d65 100644
--- a/ios/Tests/GutenbergKitTests/Services/EditorServiceTests.swift
+++ b/ios/Tests/GutenbergKitTests/Services/EditorServiceTests.swift
@@ -112,6 +112,203 @@ struct EditorServiceTests: MakesTestFixtures {
#expect(!postRequests.isEmpty, "Should request /posts/123 for positive post IDs")
}
+ // MARK: - Progress
+
+ /// The first `prepare()` to finish clears the service's progress while the other still has
+ /// progress to report, so `incrementProgress` has to drop late progress rather than trap on it.
+ /// A shared bundle build delivers the same late progress to a service whose `prepare()` has
+ /// given up on it.
+ @Test("overlapping prepare() calls on one service don't trap on each other's progress")
+ func overlappingPrepareCallsDontTrap() async throws {
+ let client = GatedHTTPClient(respond: Self.editorServiceResponseHandler)
+ let service = EditorService(
+ configuration: makeConfiguration(),
+ httpClient: client,
+ storageRoot: .randomTemporaryDirectory,
+ cacheRoot: .randomTemporaryDirectory
+ )
+
+ let first = Task { try await GatedHTTPClient.$caller.withValue("first") { try await service.prepare() } }
+ let second = Task { try await GatedHTTPClient.$caller.withValue("second") { try await service.prepare() } }
+ try await waitUntil { client.isHolding("first") && client.isHolding("second") }
+
+ client.release("first")
+ _ = try await first.value
+ client.release("second")
+ _ = try await second.value
+ }
+
+ // MARK: - Cache Policy
+
+ @Test("prepare() under .always uses the bundle on disk without checking the manifest")
+ func prepareUnderAlwaysUsesBundleOnDisk() async throws {
+ let site = TestSite(configuration: makeConfiguration(), manifest: Self.pluginManifest(version: "1"))
+ let bundle = try await site.service(cachePolicy: .always).prepare().assetBundle
+
+ let again = try await site.service(cachePolicy: .always).prepare().assetBundle
+
+ #expect(again.id == bundle.id)
+ #expect(site.manifestRequestCount == 1)
+ #expect(site.client.downloadCallCount == 1)
+ }
+
+ @Test("prepare() under .ignore checks the manifest, and keeps the bundle when it hasn't changed")
+ func prepareUnderIgnoreKeepsUnchangedBundle() async throws {
+ let site = TestSite(configuration: makeConfiguration(), manifest: Self.pluginManifest(version: "1"))
+ let bundle = try await site.service(cachePolicy: .always).prepare().assetBundle
+
+ let refreshed = try await site.service(cachePolicy: .ignore).prepare().assetBundle
+
+ #expect(refreshed.id == bundle.id)
+ #expect(site.manifestRequestCount == 2)
+ #expect(site.client.downloadCallCount == 1)
+ }
+
+ @Test("prepare() under .ignore picks up a changed manifest, which later editors then load")
+ func prepareUnderIgnorePicksUpChangedManifest() async throws {
+ let site = TestSite(configuration: makeConfiguration(), manifest: Self.pluginManifest(version: "1"))
+ let bundle = try await site.service(cachePolicy: .always).prepare().assetBundle
+
+ site.manifest = Self.pluginManifest(version: "2")
+ let refreshed = try await site.service(cachePolicy: .ignore).prepare().assetBundle
+ let afterwards = try await site.service(cachePolicy: .always).prepare().assetBundle
+
+ #expect(refreshed.id != bundle.id)
+ #expect(afterwards.id == refreshed.id)
+ #expect(site.client.downloadCallCount == 2)
+ }
+
+ @Test("prepare() uses the bundle on disk without checking the manifest by default")
+ func prepareUsesBundleOnDiskByDefault() async throws {
+ let site = TestSite(configuration: makeConfiguration(), manifest: Self.pluginManifest(version: "1"))
+ let bundle = try await site.service(cachePolicy: .always).prepare().assetBundle
+
+ let again = try await EditorService(
+ configuration: site.configuration,
+ httpClient: site.client,
+ storageRoot: site.storageRoot,
+ cacheRoot: site.cacheRoot
+ ).prepare().assetBundle
+
+ #expect(again == bundle)
+ #expect(site.manifestRequestCount == 1)
+ }
+
+ @Test("prepare() under .maxAge checks the manifest only once the last check is older than the age")
+ func prepareUnderMaxAgeChecksOnceExpired() async throws {
+ let site = TestSite(configuration: makeConfiguration(), manifest: Self.pluginManifest(version: "1"))
+ let bundle = try await site.service(cachePolicy: .always).prepare().assetBundle
+
+ _ = try await site.service(cachePolicy: .maxAge(3600)).prepare()
+ #expect(site.manifestRequestCount == 1)
+
+ try site.setLastManifestCheck(of: bundle, to: Date(timeIntervalSinceNow: -7200))
+ let checked = try await site.service(cachePolicy: .maxAge(3600)).prepare().assetBundle
+ #expect(site.manifestRequestCount == 2)
+ #expect(checked == bundle)
+
+ // The check started the bundle's age over
+ _ = try await site.service(cachePolicy: .maxAge(3600)).prepare()
+ #expect(site.manifestRequestCount == 2)
+ #expect(site.client.downloadCallCount == 1)
+ }
+
+ @Test("a manifest check that fails fails prepare(), and leaves what's on disk for later editors")
+ func failedManifestCheckLeavesDiskUntouched() async throws {
+ let site = TestSite(configuration: makeConfiguration(), manifest: Self.pluginManifest(version: "1"))
+ let bundle = try await site.service(cachePolicy: .always).prepare().assetBundle
+
+ site.isOffline = true
+ await #expect(throws: URLError.self) {
+ try await site.service(cachePolicy: .ignore).prepare()
+ }
+
+ #expect(try await site.service(cachePolicy: .always).prepare().assetBundle == bundle)
+ #expect(try await site.service(cachePolicy: .always).fetchAssetBundleCount() == 1)
+ }
+
+ @Test("cleanup() keeps a superseded bundle that dependencies prepared earlier still use")
+ func cleanupKeepsBundleStillInUse() async throws {
+ let site = TestSite(configuration: makeConfiguration(), manifest: Self.pluginManifest(version: "1"))
+ let held = try await site.service(cachePolicy: .always).prepare()
+
+ site.manifest = Self.pluginManifest(version: "2")
+ let refreshed = try await site.service(cachePolicy: .ignore).prepare()
+ try await site.service(cachePolicy: .always).cleanup()
+
+ #expect(refreshed.assetBundle.id != held.assetBundle.id)
+ #expect((try? held.assetBundle.getEditorRepresentation() as EditorAssetBundle.EditorRepresentation) != nil)
+ }
+
+ @Test("with automatic fallback, a refresh that can't reach the site returns what's on disk")
+ func failedRefreshFallsBackToDisk() async throws {
+ let configuration = makeConfiguration().toBuilder().setNetworkFallbackMode(.automatic).build()
+ let site = TestSite(configuration: configuration, manifest: Self.pluginManifest(version: "1"))
+ let prepared = try await site.service(cachePolicy: .always).prepare()
+
+ site.isOffline = true
+ let refreshed = try await site.service(cachePolicy: .ignore).prepare()
+
+ #expect(refreshed.assetBundle.assetCount == 1)
+ #expect(refreshed == prepared)
+ }
+
+ @Test("with automatic fallback, prepare() returns empty dependencies when offline with nothing on disk")
+ func offlineWithNothingOnDiskReturnsEmptyDependencies() async throws {
+ let configuration = makeConfiguration().toBuilder().setNetworkFallbackMode(.automatic).build()
+ let site = TestSite(configuration: configuration, manifest: Self.pluginManifest(version: "1"))
+ site.isOffline = true
+
+ let dependencies = try await site.service(cachePolicy: .ignore).prepare()
+
+ #expect(dependencies.editorSettings == .undefined)
+ #expect(dependencies.assetBundle.assetCount == 0)
+ #expect(dependencies.preloadList == nil)
+ }
+
+ // MARK: - Automatic Cleanup
+
+ @Test("a site's old bundles are cleaned up, whichever site was prepared first that day")
+ func automaticCleanupRunsPerSite() async throws {
+ let first = TestSite(
+ configuration: makeConfiguration(siteURL: Self.uniqueSiteURL()), manifest: Self.pluginManifest(version: "1"))
+ let second = TestSite(
+ configuration: makeConfiguration(siteURL: Self.uniqueSiteURL()), manifest: Self.pluginManifest(version: "2"))
+ defer { Self.forgetAutomaticCleanups(of: [first, second]) }
+ try await plantBundles(
+ forManifests: [Self.pluginManifest(version: "1"), Self.pluginManifest(version: "2")],
+ in: second.storageRoot
+ )
+ #expect(try await second.service(cachePolicy: .always).fetchAssetBundleCount() == 2)
+
+ _ = try await first.service(cachePolicy: .always).prepare()
+ _ = try await second.service(cachePolicy: .always).prepare()
+
+ #expect(try await second.service(cachePolicy: .always).fetchAssetBundleCount() == 1)
+ }
+
+ // MARK: - Progress Totals
+
+ @Test("prepare() progress never passes its total, and ends on it")
+ func prepareProgressStaysWithinTotal() async throws {
+ let scripts = ["a", "b", "c", "d"]
+ .map { #""# }
+ .joined()
+ let site = TestSite(
+ configuration: makeConfiguration(),
+ manifest: #"{"scripts":"\#(scripts)","styles":"","allowed_block_types":[]}"#
+ )
+ let tracker = ProgressTracker()
+
+ _ = try await site.service(cachePolicy: .always).prepare { tracker.append($0) }
+
+ let completed = tracker.updates.map(\.completed)
+ #expect(site.client.downloadCallCount == 4)
+ #expect(tracker.updates.allSatisfy { $0.completed <= $0.total })
+ #expect(tracker.updates.last?.completed == tracker.updates.last?.total)
+ #expect(completed == completed.sorted())
+ }
+
// MARK: - Test Helpers
/// URL-based response handler for EditorService.prepare() tests.
@@ -137,4 +334,122 @@ struct EditorServiceTests: MakesTestFixtures {
return Data("{}".utf8)
}
}
+
+ /// A manifest with one plugin script, whose URL carries `version` the way WordPress versions its
+ /// assets.
+ private static func pluginManifest(version: String) -> String {
+ #"{"scripts":"","styles":"","allowed_block_types":[]}"#
+ }
+
+ /// One site's server and storage, shared by every service a test makes for it — as a host's
+ /// services for one site share them.
+ private final class TestSite {
+ let configuration: EditorConfiguration
+ let client = EditorAssetLibraryMockHTTPClient()
+ let storageRoot = URL.randomTemporaryDirectory
+ let cacheRoot = URL.randomTemporaryDirectory
+
+ /// What the site's `editor-assets` endpoint answers.
+ var manifest: String {
+ didSet { serve() }
+ }
+
+ /// Whether every request to the site fails as it would with no connection.
+ var isOffline = false {
+ didSet { serve() }
+ }
+
+ var manifestRequestCount: Int {
+ client.requestedURLs.filter { $0.absoluteString.contains("editor-assets") }.count
+ }
+
+ init(configuration: EditorConfiguration, manifest: String) {
+ self.configuration = configuration
+ self.manifest = manifest
+ serve()
+ }
+
+ func service(cachePolicy: EditorCachePolicy) -> EditorService {
+ EditorService(
+ configuration: configuration,
+ httpClient: client,
+ cachePolicy: cachePolicy,
+ storageRoot: storageRoot,
+ cacheRoot: cacheRoot
+ )
+ }
+
+ /// Makes it look as though the site's manifest was last found to match `bundle` at `date`.
+ func setLastManifestCheck(of bundle: EditorAssetBundle, to date: Date) throws {
+ try EditorAssetBundle(
+ manifest: bundle.manifest,
+ downloadDate: bundle.downloadDate,
+ lastCheckedDate: date,
+ bundleRoot: bundle.bundleRoot
+ ).writeManifest()
+ }
+
+ private func serve() {
+ client.urlResponseHandler = { [manifest, isOffline] url in
+ guard !isOffline else { throw URLError(.notConnectedToInternet) }
+ return url.absoluteString.contains("editor-assets")
+ ? Data(manifest.utf8) : EditorServiceTests.editorServiceResponseHandler(url)
+ }
+ }
+ }
+
+ /// A site no other test, and no earlier run, has prepared.
+ private static func uniqueSiteURL() -> URL {
+ URL(string: "https://\(UUID().uuidString.lowercased()).example")!
+ }
+
+ /// Removes the record of when each site's bundles were last cleaned up automatically, which
+ /// would otherwise outlive the test in the user defaults.
+ private static func forgetAutomaticCleanups(of sites: [TestSite]) {
+ let hosts = sites.compactMap { $0.configuration.siteURL.host() }
+ for key in UserDefaults.standard.dictionaryRepresentation().keys
+ where key.hasPrefix("once-every-") && hosts.contains(where: key.contains) {
+ UserDefaults.standard.removeObject(forKey: key)
+ }
+ }
+}
+
+/// Answers every request from `respond`, but holds each one until the test releases the caller
+/// that made it — named by ``caller``, which a test sets around the work it starts.
+private final class GatedHTTPClient: EditorHTTPClientProtocol, @unchecked Sendable {
+ @TaskLocal static var caller = ""
+
+ private let respond: @Sendable (URL) -> Data
+ private let lock = NSLock()
+ private var holding: [String: Int] = [:]
+ private var released: Set = []
+
+ init(respond: @escaping @Sendable (URL) -> Data) {
+ self.respond = respond
+ }
+
+ /// Whether a request from `caller` is being held.
+ func isHolding(_ caller: String) -> Bool {
+ lock.withLock { holding[caller, default: 0] > 0 }
+ }
+
+ /// Lets every request from `caller`, held or still to come, through.
+ func release(_ caller: String) {
+ lock.withLock { _ = released.insert(caller) }
+ }
+
+ func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) {
+ let caller = Self.caller
+ lock.withLock { holding[caller, default: 0] += 1 }
+ defer { lock.withLock { holding[caller, default: 0] -= 1 } }
+ while !lock.withLock({ released.contains(caller) }) {
+ try await Task.sleep(for: .milliseconds(5))
+ }
+ let url = try #require(urlRequest.url)
+ return (respond(url), try #require(HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)))
+ }
+
+ func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) {
+ throw URLError(.unsupportedURL)
+ }
}
diff --git a/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift b/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift
index 09efb4ca8..f9c195129 100644
--- a/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift
+++ b/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift
@@ -23,6 +23,29 @@ struct RESTAPIRepositoryTests: MakesTestFixtures {
#expect(mockClient.getCallCount == 1)
}
+ /// An editor reopened on a post must not join the request an editor since closed still has
+ /// in flight for it: that one can predate an edit made in between.
+ @Test("fetchPost goes out for every caller, even with an identical request in flight")
+ func fetchPostIsNeverShared() async throws {
+ let session = ParkedURLSession()
+ defer { session.release() }
+ let configuration = makeConfiguration(postID: 5)
+ let repositories = (0..<2).map { _ in
+ makeRepository(
+ configuration: configuration,
+ httpClient: EditorHTTPClient(urlSession: session, authHeader: configuration.authHeader)
+ )
+ }
+
+ let fetches = repositories.map { repository in Task { try await repository.fetchPost(id: 5) } }
+ try await waitUntil { session.requestCount == 2 }
+
+ session.release() // fails both parked requests
+ for fetch in fetches {
+ await #expect(throws: URLError.self) { try await fetch.value }
+ }
+ }
+
// MARK: - fetchEditorSettings Tests
@Test("fetchEditorSettings returns undefined when theme styles disabled")
diff --git a/ios/Tests/GutenbergKitTests/Stores/EditorAssetLibraryTests.swift b/ios/Tests/GutenbergKitTests/Stores/EditorAssetLibraryTests.swift
index 320f953bc..3c45670e9 100644
--- a/ios/Tests/GutenbergKitTests/Stores/EditorAssetLibraryTests.swift
+++ b/ios/Tests/GutenbergKitTests/Stores/EditorAssetLibraryTests.swift
@@ -87,8 +87,35 @@ struct EditorAssetLibraryTests {
#expect(manifest.rawStyles.contains("plugin.css"))
}
- @Test("fetchManifest with ignore cache policy always fetches new data")
- func fetchManifestIgnoreCachePolicyAlwaysFetches() async throws {
+ /// On iOS 17 and 18, `URL.appending(path:)` keeps both slashes when the root ends in
+ /// one and the path starts with one, and WordPress answers `/wp-json//wpcom/…` with
+ /// a 404.
+ @Test(
+ "fetchManifest requests one URL whether or not the API root ends in a slash",
+ arguments: ["https://example.com/wp-json", "https://example.com/wp-json/"]
+ )
+ func fetchManifestJoinsTheAPIRootWithOneSlash(siteApiRoot: String) async throws {
+ let configuration = EditorConfigurationBuilder(
+ postType: .post,
+ siteURL: URL(string: "https://example.com")!,
+ siteApiRoot: URL(string: siteApiRoot)!
+ )
+ .setShouldUsePlugins(true)
+ .build()
+
+ let mockClient = EditorAssetLibraryMockHTTPClient()
+ mockClient.urlResponseHandler = { _ in Data(#"{"scripts": "", "styles": "", "allowed_block_types": []}"#.utf8) }
+
+ let library = makeLibrary(configuration: configuration, httpClient: mockClient, cachePolicy: .ignore)
+ _ = try await library.fetchManifest()
+
+ #expect(mockClient.requestedURLs.map(\.absoluteString) == [
+ "https://example.com/wp-json/wpcom/v2/editor-assets?exclude=core,gutenberg"
+ ])
+ }
+
+ @Test("fetchManifest requests the manifest on every call")
+ func fetchManifestRequestsOnEveryCall() async throws {
let manifestJSON = """
{
"scripts": "",
@@ -100,7 +127,7 @@ struct EditorAssetLibraryTests {
let mockClient = EditorAssetLibraryMockHTTPClient()
mockClient.urlResponseHandler = { _ in Data(manifestJSON.utf8) }
- let library = makeLibrary(httpClient: mockClient, cachePolicy: .ignore)
+ let library = makeLibrary(httpClient: mockClient)
_ = try await library.fetchManifest()
_ = try await library.fetchManifest()
@@ -108,41 +135,50 @@ struct EditorAssetLibraryTests {
#expect(mockClient.getCallCount == 2)
}
- @Test("fetchManifest with always cache policy returns cached manifest when bundle exists on disk")
- func fetchManifestAlwaysCachePolicyReturnsCachedManifest() async throws {
+ @Test("fetchManifest returns the manifest of a matching bundle on disk, even under .ignore")
+ func fetchManifestReturnsMatchingBundleManifest() async throws {
let manifestJSON = uniqueManifestJSON(identifier: "test-cached-manifest-\(UUID().uuidString)")
let mockClient = EditorAssetLibraryMockHTTPClient()
mockClient.urlResponseHandler = { _ in Data(manifestJSON.utf8) }
- let library = makeLibrary(httpClient: mockClient, cachePolicy: .always)
+ // The policy decides whether to check the manifest at all, not what to make of the answer
+ let library = makeLibrary(httpClient: mockClient, cachePolicy: .ignore)
// First, fetch the manifest and create a bundle on disk
let originalManifest = try await library.fetchManifest()
- _ = try await library.buildBundle(for: originalManifest)
+ let bundle = try await library.buildBundle(for: originalManifest)
+
+ // Mark the copy on disk, so that it can be told apart from the response parsed again
+ let manifestPath = await library.bundleManifestPath(for: bundle)
+ var stored = try #require(try JSONSerialization.jsonObject(with: Data(contentsOf: manifestPath)) as? [String: Any])
+ var storedManifest = try #require(stored["manifest"] as? [String: Any])
+ storedManifest["allowedBlockTypes"] = ["only/on-disk"]
+ stored["manifest"] = storedManifest
+ try JSONSerialization.data(withJSONObject: stored).write(to: manifestPath, options: .atomic)
// Now fetch again - should return the on-disk manifest
let cachedManifest = try await library.fetchManifest()
- // The checksums should match since it's the same manifest data
#expect(cachedManifest.checksum == originalManifest.checksum)
+ #expect(cachedManifest.allowedBlockTypes == ["only/on-disk"])
// Verify we made 2 HTTP calls (one for each fetchManifest)
// but the second one used the cached bundle's manifest
#expect(mockClient.getCallCount == 2)
}
- @Test("fetchManifest with always cache policy falls back to new manifest when no bundle exists")
- func fetchManifestAlwaysCachePolicyFallsBackWhenNoBundleExists() async throws {
+ @Test("fetchManifest parses a new manifest when no bundle matches")
+ func fetchManifestParsesWhenNoBundleMatches() async throws {
let manifestJSON = uniqueManifestJSON(identifier: "test-no-cache-fallback-\(UUID().uuidString)")
let mockClient = EditorAssetLibraryMockHTTPClient()
mockClient.urlResponseHandler = { _ in Data(manifestJSON.utf8) }
- let library = makeLibrary(httpClient: mockClient, cachePolicy: .always)
+ let library = makeLibrary(httpClient: mockClient)
- // Fetch with always cache policy when no bundle exists on disk
+ // Fetch when no bundle exists on disk
let manifest = try await library.fetchManifest()
// Should still return a valid manifest (created from remote data)
@@ -150,9 +186,8 @@ struct EditorAssetLibraryTests {
#expect(mockClient.getCallCount == 1)
}
- @Test(
- "fetchManifest with always cache policy avoids expensive LocalEditorAssetManifest creation when cached")
- func fetchManifestAlwaysCachePolicyAvoidsExpensiveCreation() async throws {
+ @Test("fetchManifest avoids expensive LocalEditorAssetManifest creation when a bundle matches")
+ func fetchManifestAvoidsExpensiveCreationWhenBundleMatches() async throws {
// Use a manifest with multiple block types but no scripts/styles to avoid download issues
let manifestJSON = """
{
@@ -165,7 +200,7 @@ struct EditorAssetLibraryTests {
let mockClient = EditorAssetLibraryMockHTTPClient()
mockClient.urlResponseHandler = { _ in Data(manifestJSON.utf8) }
- let library = makeLibrary(httpClient: mockClient, cachePolicy: .always)
+ let library = makeLibrary(httpClient: mockClient)
// First fetch and create the bundle
let originalManifest = try await library.fetchManifest()
@@ -313,125 +348,286 @@ struct EditorAssetLibraryTests {
#expect(retrievedBundle.manifest.allowedBlockTypes == blockTypes)
}
- // MARK: - CachePolicy Tests
+ // MARK: - Cache Policy Tests
- @Test("EditorCachePolicy.always is default behavior")
- func editorCachePolicyAlwaysIsDefault() async throws {
- let manifestJSON = """
- {
- "scripts": "",
- "styles": "",
- "allowed_block_types": []
- }
- """
+ @Test("readLatestAssetBundle returns nil when there are no bundles")
+ func readLatestAssetBundleReturnsNilWithoutBundles() async throws {
+ let library = makeLibrary(cachePolicy: .always)
- let mockClient = EditorAssetLibraryMockHTTPClient()
- mockClient.urlResponseHandler = { _ in Data(manifestJSON.utf8) }
+ #expect(try await library.readLatestAssetBundle() == nil)
+ }
- let library = makeLibrary(httpClient: mockClient)
+ @Test("readLatestAssetBundle returns the newest bundle, however old, under .always")
+ func readLatestAssetBundleIgnoresAgeUnderAlways() async throws {
+ let (library, _) = try await makeLibraryWithBundle(cachePolicy: .always)
+ let bundle = try #require(try await library.readAssetBundles().first)
+ try backdate(bundle, by: 365 * 86_400)
- // Call fetchManifest with default cache policy
- _ = try await library.fetchManifest()
+ #expect(try await library.readLatestAssetBundle()?.id == bundle.id)
+ }
- // The HTTP client should have been called
- #expect(mockClient.getCallCount == 1)
+ @Test("readLatestAssetBundle returns nil under .ignore, even for a new bundle")
+ func readLatestAssetBundleReturnsNilUnderIgnore() async throws {
+ let (library, _) = try await makeLibraryWithBundle(cachePolicy: .ignore)
+
+ #expect(try await library.readAssetBundles().count == 1)
+ #expect(try await library.readLatestAssetBundle() == nil)
}
- @Test("EditorCachePolicy.maxAge uses cached manifest when within timeout")
- func editorCachePolicyMaxAgeUsesCachedWhenWithinTimeout() async throws {
- let manifestJSON = uniqueManifestJSON(identifier: "test-maxage-within-\(UUID().uuidString)")
+ @Test("readLatestAssetBundle returns a bundle younger than .maxAge")
+ func readLatestAssetBundleReturnsBundleWithinMaxAge() async throws {
+ let (library, _) = try await makeLibraryWithBundle(cachePolicy: .maxAge(3600))
+ let bundle = try #require(try await library.readAssetBundles().first)
+ try backdate(bundle, by: 1800)
- let mockClient = EditorAssetLibraryMockHTTPClient()
- mockClient.urlResponseHandler = { _ in Data(manifestJSON.utf8) }
+ #expect(try await library.readLatestAssetBundle()?.id == bundle.id)
+ }
- // Set maxAge to 1 hour (3600 seconds)
- let library = makeLibrary(httpClient: mockClient, cachePolicy: .maxAge(3600))
+ @Test("readLatestAssetBundle returns nil for a bundle older than .maxAge")
+ func readLatestAssetBundleReturnsNilPastMaxAge() async throws {
+ let (library, _) = try await makeLibraryWithBundle(cachePolicy: .maxAge(3600))
+ let bundle = try #require(try await library.readAssetBundles().first)
+ try backdate(bundle, by: 7200)
- // First fetch and create the bundle
- let originalManifest = try await library.fetchManifest()
- _ = try await library.buildBundle(for: originalManifest)
+ #expect(try await library.readLatestAssetBundle() == nil)
+ }
- // Second fetch should use cached manifest since we're within the 1 hour timeout
- let cachedManifest = try await library.fetchManifest()
+ @Test("downloadAssetBundle keeps a bundle whose manifest hasn't changed, and marks it current")
+ func downloadAssetBundleKeepsUnchangedBundle() async throws {
+ let (library, mockClient) = try await makeLibraryWithBundle(cachePolicy: .maxAge(3600))
+ let bundle = try #require(try await library.readAssetBundles().first)
+ try backdate(bundle, by: 7200)
+ #expect(mockClient.downloadCallCount == 1)
- #expect(cachedManifest.checksum == originalManifest.checksum)
- // Should have made 2 HTTP calls but second one used cached bundle
- #expect(mockClient.getCallCount == 2)
+ let checked = try await library.downloadAssetBundle()
+
+ #expect(checked.id == bundle.id)
+ #expect(mockClient.getCallCount == 2) // The manifest, checked again
+ #expect(mockClient.downloadCallCount == 1) // Its asset, not downloaded again
+ #expect(try await library.readAssetBundles().count == 1)
+ #expect(try await library.readLatestAssetBundle()?.id == bundle.id)
+ }
+
+ @Test("downloadAssetBundle builds a new bundle when the manifest has changed")
+ func downloadAssetBundleBuildsChangedBundle() async throws {
+ let (library, mockClient) = try await makeLibraryWithBundle(cachePolicy: .ignore)
+ let original = try #require(try await library.readAssetBundles().first)
+
+ mockClient.urlResponseHandler = Self.responses(forManifest: Self.manifestJSON(scriptVersion: "2"))
+ let changed = try await library.downloadAssetBundle()
+
+ #expect(changed.id != original.id)
+ #expect(mockClient.downloadCallCount == 2)
+ #expect(try await library.readAssetBundles().map(\.id) == [changed.id, original.id])
}
- @Test("EditorCachePolicy.maxAge fetches new manifest when timeout expired")
- func editorCachePolicyMaxAgeFetchesNewWhenExpired() async throws {
- let manifestJSON = uniqueManifestJSON(identifier: "test-maxage-expired-\(UUID().uuidString)")
+ @Test("downloadAssetBundle makes the bundle for a manifest the site went back to the newest again")
+ func downloadAssetBundleRestoresReturningBundle() async throws {
+ let (library, mockClient) = try await makeLibraryWithBundle(cachePolicy: .ignore)
+ let original = try #require(try await library.readAssetBundles().first)
+ mockClient.urlResponseHandler = Self.responses(forManifest: Self.manifestJSON(scriptVersion: "2"))
+ let changed = try await library.downloadAssetBundle()
+
+ mockClient.urlResponseHandler = Self.responses(forManifest: Self.manifestJSON(scriptVersion: "1"))
+ let restored = try await library.downloadAssetBundle()
+ #expect(restored.id == original.id)
+ #expect(mockClient.downloadCallCount == 2)
+ #expect(try await library.readAssetBundles().map(\.id) == [original.id, changed.id])
+ }
+
+ @Test("downloadAssetBundle leaves a bundle whose manifest hasn't changed exactly as it was")
+ func downloadAssetBundleLeavesUnchangedBundleAsItWas() async throws {
+ let (library, _) = try await makeLibraryWithBundle(cachePolicy: .ignore)
+ let bundle = try #require(try await library.readAssetBundles().first)
+
+ let checked = try await library.downloadAssetBundle()
+
+ // A host comparing dependencies sees no change, and the date still says when it was downloaded
+ #expect(checked == bundle)
+ #expect(checked.downloadDate == bundle.downloadDate)
+ #expect(try await library.readAssetBundles() == [bundle])
+ }
+
+ @Test("downloadAssetBundle downloads an asset that an earlier build of the bundle failed to")
+ func downloadAssetBundleRepairsMissingAsset() async throws {
let mockClient = EditorAssetLibraryMockHTTPClient()
- mockClient.urlResponseHandler = { _ in Data(manifestJSON.utf8) }
+ mockClient.urlResponseHandler = { url in
+ guard url.path.contains("editor-assets") else { throw URLError(.timedOut) }
+ return Data(Self.manifestJSON(scriptVersion: "1").utf8)
+ }
+ let library = makeLibrary(httpClient: mockClient, cachePolicy: .ignore)
+ let gapped = try await library.downloadAssetBundle()
+ #expect(!gapped.hasAssetData(for: Self.scriptURL))
- // Set maxAge to 0 seconds (immediately expired)
- let library = makeLibrary(httpClient: mockClient, cachePolicy: .maxAge(0))
+ mockClient.urlResponseHandler = Self.responses(forManifest: Self.manifestJSON(scriptVersion: "1"))
+ let repaired = try await library.downloadAssetBundle()
- // First fetch and create the bundle
- let originalManifest = try await library.fetchManifest()
- _ = try await library.buildBundle(for: originalManifest)
+ #expect(repaired.id == gapped.id)
+ #expect(repaired.hasAssetData(for: Self.scriptURL))
+ #expect(mockClient.downloadCallCount == 2)
+ }
- // Second fetch should NOT use cached manifest since maxAge(0) means immediately expired
- let newManifest = try await library.fetchManifest()
+ @Test(
+ "a manifest check asks the site afresh, rather than taking a stored response or a request in flight",
+ arguments: [EditorCachePolicy.ignore, .maxAge(3600)]
+ )
+ func manifestCheckAsksAfresh(cachePolicy: EditorCachePolicy) async throws {
+ let (library, mockClient) = try await makeLibraryWithBundle(cachePolicy: cachePolicy)
- // The checksums should still match (same data) but the cache was bypassed
- #expect(newManifest.checksum == originalManifest.checksum)
- #expect(mockClient.getCallCount == 2)
+ _ = try await library.downloadAssetBundle()
+
+ #expect(mockClient.requests.last?.cachePolicy == .reloadIgnoringLocalCacheData)
}
- @Test("EditorCachePolicy.maxAge with short timeout expires after delay")
- func editorCachePolicyMaxAgeExpiresAfterDelay() async throws {
- let manifestJSON = uniqueManifestJSON(identifier: "test-maxage-delay-\(UUID().uuidString)")
+ @Test("under .always, a manifest request can still be shared with one in flight")
+ func manifestRequestIsSharableUnderAlways() async throws {
+ let (_, mockClient) = try await makeLibraryWithBundle(cachePolicy: .always)
+ #expect(mockClient.requests.last?.cachePolicy == .useProtocolCachePolicy)
+ }
+
+ @Test("downloadAssetBundle builds a bundle again if another service's cleanup deletes it mid-check")
+ func downloadAssetBundleRebuildsBundleCleanedUpDuringCheck() async throws {
+ let storageRoot = URL.randomTemporaryDirectory
+ let planted = try await plantBundles(
+ forManifests: [Self.manifestJSON(scriptVersion: "1"), Self.manifestJSON(scriptVersion: "2")],
+ in: storageRoot
+ )
let mockClient = EditorAssetLibraryMockHTTPClient()
- mockClient.urlResponseHandler = { _ in Data(manifestJSON.utf8) }
+ let library = makeLibrary(httpClient: mockClient, cachePolicy: .ignore, storageRoot: storageRoot)
+ let other = makeLibrary(storageRoot: storageRoot)
+
+ // The site is back on the older manifest. Another service's cleanup removes that manifest's
+ // bundle — not yet the latest — after the check has found it on disk.
+ mockClient.urlResponseHandler = Self.responses(forManifest: Self.manifestJSON(scriptVersion: "1"))
+ let restored = try await library.downloadAssetBundle { _ in try? await other.cleanup() }
+
+ #expect(restored.id == planted[0].id)
+ #expect((try? restored.getEditorRepresentation() as EditorAssetBundle.EditorRepresentation) != nil)
+ #expect(restored.hasAssetData(for: Self.scriptURL))
+ #expect(try await library.readAssetBundles().first?.id == restored.id)
+ }
- // Set maxAge to 0.05 seconds (50 milliseconds)
- let library = makeLibrary(httpClient: mockClient, cachePolicy: .maxAge(0.05))
+ @Test("downloadAssetBundle builds a bundle again if a purge deletes it mid-check")
+ func downloadAssetBundleRebuildsBundlePurgedDuringCheck() async throws {
+ let (library, _) = try await makeLibraryWithBundle(cachePolicy: .ignore)
+ let bundle = try #require(try await library.readAssetBundles().first)
- // First fetch and create the bundle
- let originalManifest = try await library.fetchManifest()
- _ = try await library.buildBundle(for: originalManifest)
+ let checked = try await library.downloadAssetBundle { _ in try? await library.purge() }
- // Wait for the cache to expire
- try await Task.sleep(for: .milliseconds(100))
+ #expect(checked.id == bundle.id)
+ #expect((try? checked.getEditorRepresentation() as EditorAssetBundle.EditorRepresentation) != nil)
+ #expect(checked.hasAssetData(for: Self.scriptURL))
+ #expect(try await library.readAssetBundles().map(\.id) == [bundle.id])
+ }
- // Third fetch should bypass cache since it's expired
- _ = try await library.fetchManifest()
+ @Test("a bundle stored before checks were recorded is as old as its download")
+ func bundleWithoutLastCheckedDateUsesDownloadDate() async throws {
+ let (library, _) = try await makeLibraryWithBundle(cachePolicy: .maxAge(3600))
+ let bundle = try #require(try await library.readAssetBundles().first)
+ let manifestPath = await library.bundleManifestPath(for: bundle)
- // Both fetches should have made HTTP calls since cache expired
- #expect(mockClient.getCallCount == 2)
+ // What an earlier version wrote: the manifest and a download date, and nothing else
+ func store(downloadedAgo interval: TimeInterval) throws {
+ let stored: [String: Any] = [
+ "manifest": try JSONSerialization.jsonObject(with: JSONEncoder().encode(bundle.manifest)),
+ "downloadDate": Date(timeIntervalSinceNow: -interval).timeIntervalSinceReferenceDate
+ ]
+ try JSONSerialization.data(withJSONObject: stored).write(to: manifestPath, options: .atomic)
+ }
+
+ try store(downloadedAgo: 1800)
+ #expect(try await library.readLatestAssetBundle()?.id == bundle.id)
+
+ try store(downloadedAgo: 7200)
+ #expect(try await library.readAssetBundles().first?.lastCheckedDate == nil)
+ #expect(try await library.readLatestAssetBundle() == nil)
}
- @Test("EditorCachePolicy.maxAge uses cache before expiry then fetches after")
- func editorCachePolicyMaxAgeTransitionsCorrectly() async throws {
- let manifestJSON = uniqueManifestJSON(identifier: "test-maxage-transition-\(UUID().uuidString)")
+ // MARK: - cleanup Tests
+
+ @Test("cleanup removes the bundles an earlier launch left behind, except the newest")
+ func cleanupRemovesBundlesFromEarlierLaunch() async throws {
+ let storageRoot = URL.randomTemporaryDirectory
+ let planted = try await plantBundles(
+ forManifests: ["1", "2", "3"].map(Self.manifestJSON(scriptVersion:)),
+ in: storageRoot
+ )
+ let library = makeLibrary(storageRoot: storageRoot)
+ try await library.cleanup()
+
+ #expect(try await library.readAssetBundles().map(\.id) == [planted[2].id])
+ }
+
+ @Test("cleanup keeps the bundle the site's manifest last matched, though another was downloaded later")
+ func cleanupKeepsLatestBundleWhateverItsDownloadDate() async throws {
+ let storageRoot = URL.randomTemporaryDirectory
+ let planted = try await plantBundles(
+ forManifests: [Self.manifestJSON(scriptVersion: "1"), Self.manifestJSON(scriptVersion: "2")],
+ in: storageRoot
+ )
let mockClient = EditorAssetLibraryMockHTTPClient()
- mockClient.urlResponseHandler = { _ in Data(manifestJSON.utf8) }
+ mockClient.urlResponseHandler = Self.responses(forManifest: Self.manifestJSON(scriptVersion: "1"))
+ let library = makeLibrary(httpClient: mockClient, cachePolicy: .ignore, storageRoot: storageRoot)
- // Set maxAge to 0.1 seconds (100 milliseconds)
- let library = makeLibrary(httpClient: mockClient, cachePolicy: .maxAge(0.1))
+ _ = try await library.downloadAssetBundle()
+ try await library.cleanup()
- // First fetch and create the bundle
- let originalManifest = try await library.fetchManifest()
- _ = try await library.buildBundle(for: originalManifest)
+ #expect(try await library.readAssetBundles().map(\.id) == [planted[0].id])
+ }
- // Immediate second fetch should use cache (within 100ms)
- let cachedManifest = try await library.fetchManifest()
- #expect(cachedManifest.checksum == originalManifest.checksum)
+ @Test("cleanup keeps a bundle handed out since launch, and purge removes it anyway")
+ func cleanupKeepsHandedOutBundles() async throws {
+ let (library, mockClient) = try await makeLibraryWithBundle(cachePolicy: .ignore)
+ mockClient.urlResponseHandler = Self.responses(forManifest: Self.manifestJSON(scriptVersion: "2"))
+ _ = try await library.downloadAssetBundle()
+
+ try await library.cleanup()
+ #expect(try await library.readAssetBundles().count == 2)
- // Wait for cache to expire
- try await Task.sleep(for: .milliseconds(150))
+ try await library.purge()
+ #expect(try await library.readAssetBundles().isEmpty)
+ }
+
+ /// A library whose storage holds one bundle, built from ``manifestJSON(scriptVersion:)`` with version `1`.
+ private func makeLibraryWithBundle(
+ cachePolicy: EditorCachePolicy
+ ) async throws -> (EditorAssetLibrary, EditorAssetLibraryMockHTTPClient) {
+ let mockClient = EditorAssetLibraryMockHTTPClient()
+ mockClient.urlResponseHandler = Self.responses(forManifest: Self.manifestJSON(scriptVersion: "1"))
+ let library = makeLibrary(httpClient: mockClient, cachePolicy: cachePolicy)
+ _ = try await library.downloadAssetBundle()
+ return (library, mockClient)
+ }
- // Third fetch should create new manifest since cache expired
- let newManifest = try await library.fetchManifest()
- #expect(newManifest.checksum == originalManifest.checksum)
+ /// A manifest with one script, whose URL carries `scriptVersion` the way WordPress versions its assets.
+ private static func manifestJSON(scriptVersion: String) -> String {
+ """
+ {
+ "scripts": "",
+ "styles": "",
+ "allowed_block_types": ["core/paragraph"]
+ }
+ """
+ }
+
+ private static func responses(forManifest manifestJSON: String) -> (URL) throws -> Data {
+ { url in url.path.contains("editor-assets") ? Data(manifestJSON.utf8) : Data("mock content".utf8) }
+ }
- // Should have made 3 HTTP calls total
- #expect(mockClient.getCallCount == 3)
+ /// The script in ``manifestJSON(scriptVersion:)`` with version `1`.
+ private static let scriptURL = URL(string: "https://example.com/plugin.js?ver=1")!
+
+ /// Makes it `interval` seconds since the site's manifest was last found to match `bundle`.
+ private func backdate(_ bundle: EditorAssetBundle, by interval: TimeInterval) throws {
+ try EditorAssetBundle(
+ manifest: bundle.manifest,
+ downloadDate: bundle.downloadDate,
+ lastCheckedDate: Date(timeIntervalSinceNow: -interval),
+ bundleRoot: bundle.bundleRoot
+ ).writeManifest()
}
// MARK: - Bundle Fetching Tests with Real Manifest Data
@@ -873,6 +1069,68 @@ struct EditorAssetLibraryTests {
let failedScriptPath = bundleRoot.appending(path: "/stats.js")
#expect(!FileManager.default.fileExists(at: failedScriptPath))
}
+
+ @Test("buildBundle publishes nothing when it is cancelled mid-download")
+ func buildBundlePublishesNothingWhenCancelled() async throws {
+ let manifestJSON = """
+ {
+ "scripts": "",
+ "styles": "",
+ "allowed_block_types": ["core/paragraph"]
+ }
+ """
+ let manifest = try LocalEditorAssetManifest(
+ remoteManifest: RemoteEditorAssetManifest(data: Data(manifestJSON.utf8))
+ )
+
+ let session = ParkedURLSession()
+ defer { session.release() }
+ let library = makeLibrary(httpClient: EditorHTTPClient(urlSession: session, authHeader: "Bearer test-token"))
+ let destination = await library.bundleRoot(for: manifest.checksum).standardizedFileURL
+
+ let build = Task { try await library.buildBundle(for: manifest) }
+ try await session.waitUntilStarted()
+ let abandoned = try #require(EditorAssetLibrary.inFlightBuilds.task(for: destination))
+ build.cancel()
+
+ // The cancelled download is swallowed like any failed asset; the build must
+ // still refuse to publish, or every later launch serves the gap.
+ await #expect(throws: CancellationError.self) { try await build.value }
+ // The caller's wait ends before the build it abandoned is cancelled, so wait for the
+ // build itself: checked any sooner, a build about to publish hasn't yet.
+ await abandoned.value
+ #expect(try await library.readAssetBundles().isEmpty)
+ }
+
+ @Test("builds of one bundle share one build, whichever library runs them")
+ func buildsOfOneBundleShareOneBuild() async throws {
+ let manifestJSON = """
+ {
+ "scripts": "",
+ "styles": "",
+ "allowed_block_types": ["core/paragraph"]
+ }
+ """
+ let manifest = try LocalEditorAssetManifest(
+ remoteManifest: RemoteEditorAssetManifest(data: Data(manifestJSON.utf8))
+ )
+
+ let session = ParkedURLSession()
+ defer { session.release() }
+ let storageRoot = URL.randomTemporaryDirectory
+ let libraries = [
+ makeLibrary(httpClient: EditorHTTPClient(urlSession: session, authHeader: "Bearer test-token"), storageRoot: storageRoot),
+ makeLibrary(httpClient: EditorHTTPClient(urlSession: session, authHeader: "Bearer test-token"), storageRoot: storageRoot),
+ ]
+ let destination = await libraries[0].bundleRoot(for: manifest.checksum).standardizedFileURL
+
+ let builds = libraries.map { library in Task { try await library.buildBundle(for: manifest) } }
+ try await waitUntil { EditorAssetLibrary.inFlightBuilds.waiterCount(for: destination) == 2 }
+
+ session.release() // fails the parked download, which a build tolerates
+ #expect(try await builds[0].value == builds[1].value)
+ #expect(session.requestCount == 1)
+ }
}
// MARK: - Progress Tracker for Tests
@@ -903,10 +1161,15 @@ final class EditorAssetLibraryMockHTTPClient: EditorHTTPClientProtocol, @uncheck
var downloadedURLs: [URL] = []
private let lock = NSLock()
+ /// Requests made via `perform(_:)`, in order.
+ private var _requests: [URLRequest] = []
+ var requests: [URLRequest] {
+ lock.withLock { _requests }
+ }
+
/// URLs requested via `perform(_:)`. Use this to verify which endpoints were called.
- private var _requestedURLs: [URL] = []
var requestedURLs: [URL] {
- lock.withLock { _requestedURLs }
+ requests.compactMap(\.url)
}
/// Handler for generating response data based on request URL.
@@ -919,7 +1182,7 @@ final class EditorAssetLibraryMockHTTPClient: EditorHTTPClientProtocol, @uncheck
lock.withLock {
getCallCount += 1
- _requestedURLs.append(url)
+ _requests.append(urlRequest)
}
let responseData = try urlResponseHandler(url)
diff --git a/ios/Tests/GutenbergKitTests/Stores/EditorURLCacheTests.swift b/ios/Tests/GutenbergKitTests/Stores/EditorURLCacheTests.swift
index dd278483c..c1266c67f 100644
--- a/ios/Tests/GutenbergKitTests/Stores/EditorURLCacheTests.swift
+++ b/ios/Tests/GutenbergKitTests/Stores/EditorURLCacheTests.swift
@@ -444,4 +444,66 @@ struct EditorURLCacheAlwaysPolicyTests {
let tenYearsLater = self.referenceDate.addingTimeInterval(10 * 365 * 24 * 60 * 60)
#expect(try cache.hasData(for: testURL, httpMethod: .GET, currentDate: tenYearsLater) == true)
}
+
+ // MARK: - One store per site
+
+ /// Every service builds its own cache for a site, so caches share a file — and two stores on
+ /// one file race their opens, the loser failing for the rest of its life. With a store per
+ /// cache, at least one broke in every run.
+ @Test("caches for one site can be opened and used at the same time")
+ func cachesForOneSiteCanBeUsedAtTheSameTime() async throws {
+ for _ in 0..<20 {
+ let parent = URL.randomTemporaryDirectory
+ let caches = [
+ EditorURLCache(siteId: "site", parentDirectory: parent, cachePolicy: .always),
+ EditorURLCache(siteId: "site", parentDirectory: parent, cachePolicy: .always),
+ ]
+ // Each makes its first read at the same moment, as two services starting a fetch do.
+ await withTaskGroup { group in
+ for cache in caches {
+ group.addTask { _ = try? cache.response(for: testURL, httpMethod: .GET) }
+ }
+ }
+ for cache in caches {
+ try cache.store(makeResponse(), for: testURL, httpMethod: .GET)
+ }
+ }
+ }
+
+ /// A cache still open on a deleted file fails every read and write. While its store was
+ /// still shared, so did every cache created after the delete, for as long as it was held.
+ @Test("a cache created after deleteAll opens a new file")
+ func aCacheCreatedAfterDeleteAllOpensANewFile() throws {
+ let parent = URL.randomTemporaryDirectory
+ let stale = EditorURLCache(siteId: "site", parentDirectory: parent, cachePolicy: .always)
+ try stale.store(makeResponse(), for: testURL, httpMethod: .GET)
+
+ try EditorURLCache.deleteAll(in: parent)
+
+ // Held throughout, as an editor still open or a fetch still running holds its cache.
+ try withExtendedLifetime(stale) {
+ let cache = EditorURLCache(siteId: "site", parentDirectory: parent, cachePolicy: .always)
+ #expect(try cache.response(for: testURL, httpMethod: .GET) == nil)
+ let response = makeResponse()
+ try cache.store(response, for: testURL, httpMethod: .GET)
+ #expect(try cache.response(for: testURL, httpMethod: .GET) == response)
+ }
+ }
+
+ @Test("caches for one site can write at the same time")
+ func cachesForOneSiteCanWriteAtTheSameTime() async throws {
+ let parent = URL.randomTemporaryDirectory
+ let caches = [
+ EditorURLCache(siteId: "site", parentDirectory: parent, cachePolicy: .always),
+ EditorURLCache(siteId: "site", parentDirectory: parent, cachePolicy: .always),
+ ]
+ try await withThrowingTaskGroup { group in
+ for index in 0..<200 {
+ let cache = caches[index % caches.count]
+ let url = testURL.appending(path: "\(index % 20)")
+ group.addTask { try cache.store(makeResponse(), for: url, httpMethod: .GET) }
+ }
+ try await group.waitForAll()
+ }
+ }
}
diff --git a/ios/Tests/GutenbergKitTests/Stores/SQLiteKVCacheTests.swift b/ios/Tests/GutenbergKitTests/Stores/SQLiteKVCacheTests.swift
index ad6757900..95d741438 100644
--- a/ios/Tests/GutenbergKitTests/Stores/SQLiteKVCacheTests.swift
+++ b/ios/Tests/GutenbergKitTests/Stores/SQLiteKVCacheTests.swift
@@ -651,4 +651,114 @@ struct SQLiteKVCacheTests {
let expected = try encoder.encode(meta)
#expect(entry.metadata == expected)
}
+
+ // MARK: - One instance per file
+
+ @Test("shared hands every caller the live instance for a file")
+ func sharedHandsOutOneInstancePerFile() {
+ let directory = URL.randomTemporaryDirectory
+ let capacity = Measurement(value: 1, unit: .mebibytes)
+ let store = SQLiteKVCache.shared(handle: "test", directory: directory, diskCapacity: capacity)
+
+ #expect(SQLiteKVCache.shared(handle: "test", directory: directory, diskCapacity: capacity) === store)
+ #expect(SQLiteKVCache.shared(handle: "TEST", directory: directory, diskCapacity: capacity) === store)
+ #expect(SQLiteKVCache.shared(handle: "other", directory: directory, diskCapacity: capacity) !== store)
+ #expect(SQLiteKVCache.shared(handle: "test", directory: .randomTemporaryDirectory, diskCapacity: capacity) !== store)
+ }
+
+ @Test("shared opens a file afresh once no one is using it")
+ func sharedReopensAFileNoOneIsUsing() throws {
+ let directory = URL.randomTemporaryDirectory
+ let capacity = Measurement(value: 1, unit: .mebibytes)
+ var store: SQLiteKVCache? = SQLiteKVCache.shared(handle: "test", directory: directory, diskCapacity: capacity)
+ try store?.put(key: "durable", storageDate: referenceDate, metadata: Data(), value: Data("v"))
+ weak let released = store
+
+ store = nil
+ #expect(released == nil, "sharing should not keep a file open")
+
+ let reopened = SQLiteKVCache.shared(handle: "test", directory: directory, diskCapacity: capacity)
+ #expect(try reopened.get(key: "durable")?.value == Data("v"))
+ }
+
+ /// An instance keeps a failed open for life. While `shared` went on handing it out, every
+ /// later caller failed with it for as long as anything held it, though the file could by
+ /// then be opened.
+ @Test("shared opens a file afresh after an open that failed")
+ func sharedReopensAFileAfterAFailedOpen() throws {
+ let directory = URL.randomTemporaryDirectory
+ let capacity = Measurement(value: 1, unit: .mebibytes)
+ // A directory where the database file belongs fails the open.
+ let obstacle = directory.appending(component: "test.sqlite")
+ try FileManager.default.createDirectory(at: obstacle, withIntermediateDirectories: true)
+ let failed = SQLiteKVCache.shared(handle: "test", directory: directory, diskCapacity: capacity)
+ #expect(throws: SQLiteKVCache.Error.self) { try failed.get(key: "k") }
+
+ try FileManager.default.removeItem(at: obstacle)
+ try withExtendedLifetime(failed) {
+ let reopened = SQLiteKVCache.shared(handle: "test", directory: directory, diskCapacity: capacity)
+ #expect(reopened !== failed)
+ try reopened.put(key: "k", storageDate: referenceDate, metadata: Data(), value: Data("v"))
+ #expect(try reopened.get(key: "k")?.value == Data("v"))
+ }
+ }
+
+ @Test("forgetInstances stops sharing the instances under a directory, and no others")
+ func forgetInstancesIsScopedToADirectory() {
+ let root = URL.randomTemporaryDirectory
+ // Beside `root` rather than under it, though its path starts with `root`'s.
+ let beside = root.deletingLastPathComponent().appending(path: root.lastPathComponent + "-beside")
+ let capacity = Measurement(value: 1, unit: .mebibytes)
+ let under = SQLiteKVCache.shared(handle: "test", directory: root.appending(path: "site"), diskCapacity: capacity)
+ let other = SQLiteKVCache.shared(handle: "test", directory: beside.appending(path: "site"), diskCapacity: capacity)
+
+ SQLiteKVCache.forgetInstances(under: root)
+
+ #expect(SQLiteKVCache.shared(handle: "test", directory: root.appending(path: "site"), diskCapacity: capacity) !== under)
+ #expect(SQLiteKVCache.shared(handle: "test", directory: beside.appending(path: "site"), diskCapacity: capacity) === other)
+ }
+
+ /// `shared` hands out a fresh instance the moment the last one is released, while that
+ /// one's `deinit` may still hold the file to checkpoint its WAL. Without a busy timeout the
+ /// reopen fails on that lock every time — 200 runs out of 200 — and caches the failure.
+ @Test("shared reopens a file while its last instance is still closing")
+ func sharedReopensAFileWhileItCloses() async throws {
+ let capacity = Measurement(value: 1, unit: .mebibytes)
+ for _ in 0..<20 {
+ let directory = URL.randomTemporaryDirectory
+ let closing = ReleasableStore(SQLiteKVCache.shared(handle: "test", directory: directory, diskCapacity: capacity))
+ // Enough in the WAL that checkpointing it on close takes a moment.
+ for index in 0..<50 {
+ try closing.store?.put(
+ key: "\(index)",
+ storageDate: referenceDate,
+ metadata: Data(),
+ value: Data(repeating: 1, count: 4096)
+ )
+ }
+
+ async let released: Void = Task.detached { closing.release() }.value
+ async let reopened = Task.detached {
+ try SQLiteKVCache.shared(handle: "test", directory: directory, diskCapacity: capacity).get(key: "0")
+ }.value
+ await released
+ #expect(try await reopened != nil)
+ }
+ }
+}
+
+/// Holds the only reference to a store until `release()`, so a test can drop it from another task.
+private final class ReleasableStore: @unchecked Sendable {
+ private let lock = NSLock()
+ private var held: SQLiteKVCache?
+
+ init(_ store: SQLiteKVCache) {
+ held = store
+ }
+
+ var store: SQLiteKVCache? { lock.withLock { held } }
+
+ func release() {
+ lock.withLock { held = nil }
+ }
}
diff --git a/ios/Tests/GutenbergKitTests/TestHelpers.swift b/ios/Tests/GutenbergKitTests/TestHelpers.swift
index 2b73716b9..2d27db3b1 100644
--- a/ios/Tests/GutenbergKitTests/TestHelpers.swift
+++ b/ios/Tests/GutenbergKitTests/TestHelpers.swift
@@ -1,7 +1,29 @@
import Foundation
+import Testing
@testable import GutenbergKit
+/// How long a test waits for something that is supposed to happen before giving up.
+///
+/// Generous, because a wait that succeeds returns as soon as it can and only one that is going to
+/// fail runs this long. A run's first results take half a minute to arrive on a busy CI machine,
+/// which a shorter wait reads as a failure.
+let patientTimeout: Duration = .seconds(60)
+
+/// Polls `condition` until it holds, failing the test at the caller's line if it hasn't within
+/// `timeout`.
+func waitUntil(
+ timeout: Duration = patientTimeout,
+ sourceLocation: SourceLocation = #_sourceLocation,
+ _ condition: () -> Bool
+) async throws {
+ let deadline = ContinuousClock.now + timeout
+ while !condition() && ContinuousClock.now < deadline {
+ try await Task.sleep(for: .milliseconds(10))
+ }
+ try #require(condition(), "timed out waiting", sourceLocation: sourceLocation)
+}
+
func jsonResource(named name: String) throws -> Data {
let url = Bundle.module.url(forResource: name, withExtension: "json")!
return try Data(contentsOf: url)
@@ -11,6 +33,44 @@ func jsonResource(named name: String) throws -> String {
String(data: try jsonResource(named: name), encoding: .utf8)!
}
+/// Puts a bundle for each manifest under `storageRoot` the way an earlier launch would have left
+/// them: complete on disk, the last the latest, but not handed out by this process.
+@discardableResult
+func plantBundles(
+ forManifests manifests: [String],
+ in storageRoot: URL,
+ configuration: EditorConfiguration = EditorAssetLibraryTests.testConfiguration
+) async throws -> [EditorAssetBundle] {
+ let scratchRoot = URL.randomTemporaryDirectory
+ let client = EditorAssetLibraryMockHTTPClient()
+ let library = EditorAssetLibrary(configuration: configuration, httpClient: client, storageRoot: scratchRoot)
+ try FileManager.default.createDirectory(at: storageRoot, withIntermediateDirectories: true)
+
+ var planted: [EditorAssetBundle] = []
+ for manifest in manifests {
+ client.urlResponseHandler = { url in
+ url.path.contains("editor-assets") ? Data(manifest.utf8) : Data("mock content".utf8)
+ }
+ let bundle = try await library.downloadAssetBundle()
+ let destination = storageRoot.appending(path: bundle.id)
+ try FileManager.default.moveItem(at: scratchRoot.appending(path: bundle.id), to: destination)
+ planted.append(try EditorAssetBundle(url: destination.appending(path: "manifest.json")))
+ }
+ return planted
+}
+
+extension Data {
+ /// Whether this holds the same bytes as `other`, for an `#expect`.
+ ///
+ /// Not `==` in the `#expect` itself: Swift Testing describes a failed `==` between two
+ /// collections by working out the difference between them. For megabytes of bytes that takes
+ /// most of an hour on the thread the test runs on — and on the main actor, every other
+ /// main-actor test in the run waits behind it.
+ func hasSameBytes(as other: Data) -> Bool {
+ self == other
+ }
+}
+
protocol MakesTestFixtures {
static var testSiteURL: URL { get }
static var testApiRoot: URL { get }
diff --git a/src/components/native-inserter/index.jsx b/src/components/native-inserter/index.jsx
index 6d46d6787..96e2fc801 100644
--- a/src/components/native-inserter/index.jsx
+++ b/src/components/native-inserter/index.jsx
@@ -41,6 +41,7 @@ import {
formatPatternCategoriesForNativeInserter,
} from '../../utils/blocks';
import { showBlockInserter } from '../../utils/bridge';
+import { requestNativeFiles, withMimeType } from '../../utils/native-files';
import { unlock } from '../../lock-unlock';
/**
@@ -175,6 +176,27 @@ export default function NativeBlockInserterButton( {
return false;
}
+ // Native code offers the files it imported — the items marked
+ // `nativeFile` — through a file input. Ask before the first
+ // `await`: the click needs this script's user activation.
+ const nativeFileCount = mediaArray.filter(
+ ( media ) => media.nativeFile
+ ).length;
+ const nativeFilesRequest =
+ nativeFileCount > 0
+ ? requestNativeFiles().then(
+ ( files ) =>
+ files.length === nativeFileCount ? files : null,
+ ( error ) => {
+ debug(
+ 'Native files unavailable; fetching them instead',
+ error
+ );
+ return null;
+ }
+ )
+ : Promise.resolve( null );
+
/**
* Get media type from MIME type.
*
@@ -276,10 +298,26 @@ export default function NativeBlockInserterButton( {
* @return {Promise} True if insertion succeeded
*/
const insertMediaWithoutIds = async ( items ) => {
- // Convert media objects to File objects
+ // Convert media objects to File objects. Files native code
+ // provided come in the order of the items marked `nativeFile`;
+ // without them, every item is fetched.
+ const nativeFiles = await nativeFilesRequest;
+ let nextNativeFile = 0;
+ const providedFiles = items.map( ( media ) =>
+ media.nativeFile && nativeFiles
+ ? nativeFiles[ nextNativeFile++ ]
+ : null
+ );
+
const files = await Promise.all(
- items.map( async ( media ) => {
+ items.map( async ( media, index ) => {
try {
+ if ( providedFiles[ index ] ) {
+ return withMimeType(
+ providedFiles[ index ],
+ media.type
+ );
+ }
const response = await fetch( media.url );
const blob = await response.blob();
const filename =
diff --git a/src/utils/api-fetch-post-process.test.js b/src/utils/api-fetch-post-process.test.js
index 9df3514e1..34a959d99 100644
--- a/src/utils/api-fetch-post-process.test.js
+++ b/src/utils/api-fetch-post-process.test.js
@@ -274,4 +274,92 @@ describe( "core's media upload post-process middleware", () => {
expect( error ).not.toHaveBeenCalled();
} );
+
+ describe( 'over the native upload scheme', () => {
+ const SESSION = '0f8fad5b-d9cb-469f-a165-70867728950e';
+
+ beforeEach( () => {
+ bridge.getGBKit.mockReturnValue( {
+ siteApiRoot: SITE_API_ROOT,
+ authHeader: 'Bearer test-token',
+ siteApiNamespace: [],
+ namespaceExcludedPaths: [],
+ nativeUploadScheme: 'gbk-upload',
+ } );
+ } );
+
+ /**
+ * A fake scheme whose `finish` relays WordPress's 5xx and attachment ID,
+ * with `wordpress` answering everything sent straight to the site.
+ *
+ * @param {(path: string) => unknown} wordpress Answers direct requests by URL.
+ */
+ function installScheme( wordpress ) {
+ global.fetch = vi.fn( ( url ) => {
+ const path = String( url );
+ if ( path === 'gbk-upload://upload/sessions' ) {
+ return Promise.resolve(
+ makeResponse( 201, null, { id: SESSION } )
+ );
+ }
+ if ( path.includes( `/sessions/${ SESSION }/chunks` ) ) {
+ return Promise.resolve(
+ makeResponse( 200, null, { received: 4 } )
+ );
+ }
+ if ( path.endsWith( `/sessions/${ SESSION }/finish` ) ) {
+ return Promise.resolve( makeResponse( 500, '42' ) );
+ }
+ return wordpress( path );
+ } );
+ }
+
+ it( 'retries post-process for an upload finished over the scheme', async () => {
+ installScheme( ( path ) =>
+ Promise.resolve(
+ path.includes( 'post-process' )
+ ? makeResponse( 200, null, { id: 42 } )
+ : makeResponse( 404, null )
+ )
+ );
+
+ await expect( apiFetch( uploadOptions() ) ).resolves.toEqual( {
+ id: 42,
+ } );
+
+ const paths = global.fetch.mock.calls.map( ( [ url ] ) =>
+ String( url )
+ );
+ expect( paths.at( -1 ) ).toContain(
+ `${ SITE_API_ROOT }wp/v2/media/42/post-process`
+ );
+ expect( error ).not.toHaveBeenCalled();
+ } );
+
+ it( 'relays the orphan cleanup over the scheme', async () => {
+ installScheme( ( path ) =>
+ Promise.resolve(
+ path.includes( '/media/42/delete' )
+ ? makeResponse( 200, null, { deleted: true } )
+ : makeResponse( 500, null )
+ )
+ );
+
+ await expect( apiFetch( uploadOptions() ) ).rejects.toBeDefined();
+
+ const calls = global.fetch.mock.calls;
+ const [ lastURL, lastInit ] = calls.at( -1 );
+ expect(
+ calls.filter( ( [ url ] ) =>
+ String( url ).includes( 'post-process' )
+ )
+ ).toHaveLength( 5 );
+ expect( String( lastURL ) ).toBe(
+ 'gbk-upload://upload/media/42/delete'
+ );
+ expect( JSON.parse( lastInit.body ) ).toEqual( {
+ query: '?force=true',
+ } );
+ } );
+ } );
} );
diff --git a/src/utils/api-fetch-upload-middleware.test.js b/src/utils/api-fetch-upload-middleware.test.js
index c36870b1d..44b8ef0b3 100644
--- a/src/utils/api-fetch-upload-middleware.test.js
+++ b/src/utils/api-fetch-upload-middleware.test.js
@@ -9,6 +9,7 @@ vi.mock( './bridge', () => ( {
vi.mock( './logger', () => ( {
info: vi.fn(),
+ warn: vi.fn(),
error: vi.fn(),
} ) );
diff --git a/src/utils/api-fetch-upload-scheme.test.js b/src/utils/api-fetch-upload-scheme.test.js
new file mode 100644
index 000000000..477a83b29
--- /dev/null
+++ b/src/utils/api-fetch-upload-scheme.test.js
@@ -0,0 +1,374 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ nativeMediaUploadMiddleware,
+ NATIVE_UPLOAD_CHUNK_SIZE,
+} from './api-fetch';
+import { getGBKit } from './bridge';
+import { warn } from './logger';
+
+vi.mock( './bridge', () => ( {
+ getGBKit: vi.fn( () => ( {} ) ),
+} ) );
+
+vi.mock( './logger', () => ( {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+} ) );
+
+const BASE = 'gbk-upload://upload';
+const SESSION = '0f8fad5b-d9cb-469f-a165-70867728950e';
+
+function makeNext() {
+ return vi.fn( () => Promise.resolve( { passthrough: true } ) );
+}
+
+function makeOptions( file, { path = '/wp/v2/media', fields = [] } = {} ) {
+ const body = new FormData();
+ for ( const [ name, value ] of fields ) {
+ body.append( name, value );
+ }
+ body.append( 'file', file, file.name );
+ return { method: 'POST', path, body };
+}
+
+function json( status, body, headers = {} ) {
+ return new Response( JSON.stringify( body ), {
+ status,
+ headers: { 'Content-Type': 'application/json', ...headers },
+ } );
+}
+
+/**
+ * A fake native upload scheme: records every request and answers from `routes`,
+ * keyed by the URL without the scheme base.
+ *
+ * @param {Object} routes Handlers keyed by path, e.g. `/sessions`.
+ * @return {Array} The recorded requests.
+ */
+function installScheme( routes = {} ) {
+ const requests = [];
+ global.fetch = vi.fn( async ( url, init = {} ) => {
+ const path = url.replace( BASE, '' );
+ const request = { path, init };
+ requests.push( request );
+ const key = Object.keys( routes ).find( ( route ) =>
+ new RegExp( `^${ route }$` ).test( path.split( '?' )[ 0 ] )
+ );
+ if ( key ) {
+ return routes[ key ]( request );
+ }
+ if ( path === '/sessions' ) {
+ return json( 201, { id: SESSION } );
+ }
+ if ( path.startsWith( `/sessions/${ SESSION }/chunks` ) ) {
+ return json( 200, { received: 0 } );
+ }
+ if ( path === `/sessions/${ SESSION }/finish` ) {
+ return json( 201, {
+ id: 42,
+ source_url: 'https://example.com/a.jpg',
+ } );
+ }
+ if ( path === `/sessions/${ SESSION }/cancel` ) {
+ return new Response( null, { status: 204 } );
+ }
+ return json( 404, { code: 'not_found' } );
+ } );
+ return requests;
+}
+
+describe( 'nativeMediaUploadMiddleware over the native upload scheme', () => {
+ beforeEach( () => {
+ vi.clearAllMocks();
+ getGBKit.mockReturnValue( { nativeUploadScheme: 'gbk-upload' } );
+ } );
+
+ describe( 'page files', () => {
+ it( 'sends the file in chunks, then finishes with its fields and query', async () => {
+ const requests = installScheme();
+ const size = NATIVE_UPLOAD_CHUNK_SIZE + 10;
+ const file = new File( [ new Uint8Array( size ) ], 'big.mp4', {
+ type: 'video/mp4',
+ } );
+ const options = makeOptions( file, {
+ path: '/wp/v2/media?_embed=wp:featuredmedia',
+ fields: [
+ [ 'post', '7' ],
+ [ 'field[]', 'a' ],
+ [ 'field[]', 'b' ],
+ ],
+ } );
+
+ const result = await nativeMediaUploadMiddleware(
+ options,
+ makeNext()
+ );
+
+ expect( result ).toEqual( {
+ id: 42,
+ source_url: 'https://example.com/a.jpg',
+ } );
+ expect( requests.map( ( r ) => r.path ) ).toEqual( [
+ '/sessions',
+ `/sessions/${ SESSION }/chunks?offset=0`,
+ `/sessions/${ SESSION }/chunks?offset=${ NATIVE_UPLOAD_CHUNK_SIZE }`,
+ `/sessions/${ SESSION }/finish`,
+ ] );
+ expect( JSON.parse( requests[ 0 ].init.body ) ).toEqual( {
+ filename: 'big.mp4',
+ mimeType: 'video/mp4',
+ size,
+ } );
+ expect( requests[ 1 ].init.body.byteLength ).toBe(
+ NATIVE_UPLOAD_CHUNK_SIZE
+ );
+ expect( requests[ 2 ].init.body.byteLength ).toBe( 10 );
+ expect( JSON.parse( requests[ 3 ].init.body ) ).toEqual( {
+ fields: [
+ { name: 'post', value: '7' },
+ { name: 'field[]', value: 'a' },
+ { name: 'field[]', value: 'b' },
+ ],
+ query: '?_embed=wp:featuredmedia',
+ } );
+ } );
+
+ it( 'finishes an empty file without sending a chunk', async () => {
+ const requests = installScheme();
+ const file = new File( [], 'empty.txt', { type: 'text/plain' } );
+
+ await nativeMediaUploadMiddleware(
+ makeOptions( file ),
+ makeNext()
+ );
+
+ expect( requests.map( ( r ) => r.path ) ).toEqual( [
+ '/sessions',
+ `/sessions/${ SESSION }/finish`,
+ ] );
+ } );
+
+ it( 'passes the signal to every request', async () => {
+ const requests = installScheme();
+ const controller = new AbortController();
+ const options = {
+ ...makeOptions( new File( [ 'x' ], 'a.jpg' ) ),
+ signal: controller.signal,
+ };
+
+ await nativeMediaUploadMiddleware( options, makeNext() );
+
+ for ( const request of requests ) {
+ expect( request.init.signal ).toBe( controller.signal );
+ }
+ } );
+
+ it( 'resolves with the raw Response under parse: false', async () => {
+ installScheme();
+ const options = {
+ ...makeOptions( new File( [ 'x' ], 'a.jpg' ) ),
+ parse: false,
+ };
+
+ const response = await nativeMediaUploadMiddleware(
+ options,
+ makeNext()
+ );
+
+ expect( response ).toBeInstanceOf( Response );
+ expect( response.status ).toBe( 201 );
+ } );
+
+ it( 'rejects with the Response under parse: false so core can recover post-processing', async () => {
+ installScheme( {
+ [ `/sessions/${ SESSION }/finish` ]: () =>
+ json(
+ 500,
+ { code: 'rest_upload_sideload_error' },
+ { 'x-wp-upload-attachment-id': '99' }
+ ),
+ } );
+ const options = {
+ ...makeOptions( new File( [ 'x' ], 'a.jpg' ) ),
+ parse: false,
+ };
+
+ const rejection = await nativeMediaUploadMiddleware(
+ options,
+ makeNext()
+ ).catch( ( error ) => error );
+
+ expect( rejection ).toBeInstanceOf( Response );
+ expect( rejection.headers.get( 'x-wp-upload-attachment-id' ) ).toBe(
+ '99'
+ );
+ } );
+
+ it( 'rejects with the parsed WordPress error by default', async () => {
+ installScheme( {
+ [ `/sessions/${ SESSION }/finish` ]: () =>
+ json( 413, {
+ code: 'rest_upload_file_too_big',
+ message: 'Too big.',
+ } ),
+ } );
+
+ await expect(
+ nativeMediaUploadMiddleware(
+ makeOptions( new File( [ 'x' ], 'a.jpg' ) ),
+ makeNext()
+ )
+ ).rejects.toEqual( {
+ code: 'rest_upload_file_too_big',
+ message: 'Too big.',
+ } );
+ } );
+ } );
+
+ describe( 'falling back before finish', () => {
+ it( 'uploads through the web view when native uploads are unavailable', async () => {
+ const requests = installScheme( {
+ '/sessions': () =>
+ json( 503, { code: 'native_upload_unavailable' } ),
+ } );
+ const next = makeNext();
+ const options = makeOptions( new File( [ 'x' ], 'a.jpg' ) );
+
+ const result = await nativeMediaUploadMiddleware( options, next );
+
+ expect( result ).toEqual( { passthrough: true } );
+ expect( next ).toHaveBeenCalledWith( options );
+ expect( requests.map( ( r ) => r.path ) ).toEqual( [
+ '/sessions',
+ ] );
+ expect( warn ).toHaveBeenCalled();
+ } );
+
+ it( 'falls back when the scheme cannot be reached at all', async () => {
+ global.fetch = vi.fn( () =>
+ Promise.reject( new TypeError( 'Load failed' ) )
+ );
+ const next = makeNext();
+
+ await nativeMediaUploadMiddleware(
+ makeOptions( new File( [ 'x' ], 'a.jpg' ) ),
+ next
+ );
+
+ expect( next ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'cancels the session and falls back when a chunk fails', async () => {
+ const requests = installScheme( {
+ [ `/sessions/${ SESSION }/chunks` ]: () =>
+ json( 409, { code: 'native_upload_offset_mismatch' } ),
+ } );
+ const next = makeNext();
+
+ await nativeMediaUploadMiddleware(
+ makeOptions( new File( [ 'x' ], 'a.jpg' ) ),
+ next
+ );
+
+ expect( next ).toHaveBeenCalledTimes( 1 );
+ expect( requests.map( ( r ) => r.path ) ).toContain(
+ `/sessions/${ SESSION }/cancel`
+ );
+ expect( requests.map( ( r ) => r.path ) ).not.toContain(
+ `/sessions/${ SESSION }/finish`
+ );
+ } );
+
+ it( 'surfaces an abort during the chunks as the cancellation, without falling back', async () => {
+ const controller = new AbortController();
+ const reason = new DOMException( 'stop', 'AbortError' );
+ const requests = installScheme( {
+ [ `/sessions/${ SESSION }/chunks` ]: () => {
+ controller.abort( reason );
+ return Promise.reject( reason );
+ },
+ } );
+ const next = makeNext();
+ const options = {
+ ...makeOptions( new File( [ 'x' ], 'a.jpg' ) ),
+ signal: controller.signal,
+ };
+
+ await expect(
+ nativeMediaUploadMiddleware( options, next )
+ ).rejects.toBe( reason );
+ expect( next ).not.toHaveBeenCalled();
+ expect( requests.map( ( r ) => r.path ) ).toContain(
+ `/sessions/${ SESSION }/cancel`
+ );
+ } );
+
+ it( 'does not fall back once finish may have reached WordPress', async () => {
+ installScheme( {
+ [ `/sessions/${ SESSION }/finish` ]: () =>
+ Promise.reject( new TypeError( 'Load failed' ) ),
+ } );
+ const next = makeNext();
+
+ await expect(
+ nativeMediaUploadMiddleware(
+ makeOptions( new File( [ 'x' ], 'a.jpg' ) ),
+ next
+ )
+ ).rejects.toMatchObject( { code: 'fetch_error' } );
+ expect( next ).not.toHaveBeenCalled();
+ } );
+ } );
+
+ describe( 'transport selection and deletion', () => {
+ it( 'prefers the scheme over a loopback server', async () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadScheme: 'gbk-upload',
+ nativeUploadPort: 8080,
+ nativeUploadToken: 'token',
+ } );
+ const requests = installScheme();
+
+ await nativeMediaUploadMiddleware(
+ makeOptions( new File( [ 'x' ], 'a.jpg' ) ),
+ makeNext()
+ );
+
+ expect( requests[ 0 ].path ).toBe( '/sessions' );
+ expect( global.fetch.mock.calls[ 0 ][ 0 ] ).toMatch(
+ /^gbk-upload:/
+ );
+ } );
+
+ it( 'relays a media deletion with its query', async () => {
+ const requests = installScheme( {
+ '/media/42/delete': () => json( 200, { deleted: true } ),
+ } );
+
+ const result = await nativeMediaUploadMiddleware(
+ { method: 'DELETE', path: '/wp/v2/media/42?force=true' },
+ makeNext()
+ );
+
+ expect( result ).toEqual( { deleted: true } );
+ expect( requests[ 0 ].init.method ).toBe( 'POST' );
+ expect( JSON.parse( requests[ 0 ].init.body ) ).toEqual( {
+ query: '?force=true',
+ } );
+ } );
+
+ it( 'passes through requests that are not media uploads', async () => {
+ installScheme();
+ const next = makeNext();
+
+ await nativeMediaUploadMiddleware(
+ { method: 'GET', path: '/wp/v2/media' },
+ next
+ );
+
+ expect( next ).toHaveBeenCalledTimes( 1 );
+ expect( global.fetch ).not.toHaveBeenCalled();
+ } );
+ } );
+} );
diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js
index cc9a43c65..569ec414d 100644
--- a/src/utils/api-fetch.js
+++ b/src/utils/api-fetch.js
@@ -1,8 +1,7 @@
import apiFetch from '@wordpress/api-fetch';
-import { getQueryArg } from '@wordpress/url';
import { __ } from '@wordpress/i18n';
import { getGBKit, POST_FALLBACKS } from './bridge';
-import { info, error as logError } from './logger';
+import { info, warn, error as logError } from './logger';
import { ensureTrailingSlash, stripTrailingSlash } from './url';
/**
@@ -15,6 +14,14 @@ const MEDIA_UPLOAD_PATH = /^\/wp\/v2\/media(\?|$)/;
/** Matches `/wp/v2/media/`, capturing the attachment ID. */
const MEDIA_ATTACHMENT_PATH = /^\/wp\/v2\/media\/(\d+)(\?|$)/;
+/**
+ * How much of a file each request to the native upload scheme carries.
+ *
+ * Large enough that a 1 GB video is a few hundred requests, small enough that the
+ * page never holds more than one chunk's copy of a file in memory.
+ */
+export const NATIVE_UPLOAD_CHUNK_SIZE = 4 * 1024 * 1024;
+
/**
* Initializes the API fetch configuration and middleware.
*
@@ -195,13 +202,20 @@ function filterEndpointsMiddleware( options, next ) {
}
/**
- * Middleware that routes media requests through the native host's local HTTP
- * server: uploads for processing (e.g. image resizing) before they reach
- * WordPress, and attachment deletions for the editor's orphan cleanup.
+ * Middleware that routes media requests through native code: uploads for
+ * processing (e.g. image resizing) before they reach WordPress, and attachment
+ * deletions for the editor's orphan cleanup.
*
* Exported for testing only.
*
- * When the native server is not configured, requests pass through unmodified.
+ * Two transports, chosen by what the native host advertises in `GBKit`:
+ *
+ * - iOS: `nativeUploadScheme`. The file is sent in chunks to a URL scheme the
+ * editor's web view handles natively.
+ * - Android: `nativeUploadPort` and `nativeUploadToken`. The request goes to a
+ * loopback HTTP server.
+ *
+ * With neither, requests pass through unmodified.
*
* Note: Ideally, media uploads would be handled via the `mediaUpload` editor
* setting (see the Gutenberg Framework guides), but GutenbergKit uses
@@ -218,38 +232,64 @@ function filterEndpointsMiddleware( options, next ) {
* @type {APIFetchMiddleware}
*/
export function nativeMediaUploadMiddleware( options, next ) {
- const { nativeUploadPort, nativeUploadToken } = getGBKit();
+ const transport = nativeUploadTransport( getGBKit() );
- if ( ! nativeUploadPort || ! nativeUploadToken ) {
+ if ( ! transport ) {
return next( options );
}
// Each helper returns `null` when the request is not its concern, so an
// unhandled request falls through to the default path.
return (
- nativeMediaDelete( options, nativeUploadPort, nativeUploadToken ) ??
- nativeMediaUpload( options, nativeUploadPort, nativeUploadToken ) ??
+ nativeMediaDelete( options, transport ) ??
+ nativeMediaUpload( options, transport, next ) ??
next( options )
);
}
/**
- * Routes a media upload through the native upload server.
+ * @typedef {Object} NativeUploadTransport
+ * @property {?string} schemeBase The base URL of the native upload scheme (iOS).
+ * @property {?number} port The loopback upload server's port (Android).
+ * @property {?string} token The loopback upload server's token (Android).
+ */
+
+/**
+ * The transport the native host advertises, or `null` when it advertises none.
+ *
+ * Read on every request, so a change the host makes to `GBKit` takes effect on
+ * the next upload.
+ *
+ * @param {Object} gbkit The `GBKit` configuration.
+ * @return {?NativeUploadTransport} The transport.
+ */
+function nativeUploadTransport( gbkit ) {
+ const { nativeUploadScheme, nativeUploadPort, nativeUploadToken } = gbkit;
+ if ( nativeUploadScheme ) {
+ return { schemeBase: `${ nativeUploadScheme }://upload` };
+ }
+ if ( nativeUploadPort && nativeUploadToken ) {
+ return { port: nativeUploadPort, token: nativeUploadToken };
+ }
+ return null;
+}
+
+/**
+ * Routes a media upload through native code.
*
* Returns `null` when the request is not a media upload, so the caller falls
* through to its normal handling.
*
- * Intercepts `POST /wp/v2/media`, forwards the file to the native server, and
- * returns the response in WordPress REST API attachment format so the existing
- * Gutenberg upload pipeline (blob previews, save locking, entity caching) works
- * unchanged.
+ * Intercepts `POST /wp/v2/media`, hands the file to native code, and returns
+ * WordPress's response so the existing Gutenberg upload pipeline (blob previews,
+ * save locking, entity caching, `post-process` recovery) works unchanged.
*
- * @param {Object} options The api-fetch options.
- * @param {number} port The native upload server port.
- * @param {string} token The native upload server bearer token.
+ * @param {Object} options The api-fetch options.
+ * @param {NativeUploadTransport} transport How to reach native code.
+ * @param {(options: Object) => Promise} next The next middleware.
* @return {?Promise} The relayed upload, or `null` if not applicable.
*/
-function nativeMediaUpload( options, port, token ) {
+function nativeMediaUpload( options, transport, next ) {
if (
! options.method ||
options.method.toUpperCase() !== 'POST' ||
@@ -270,19 +310,41 @@ function nativeMediaUpload( options, port, token ) {
return null;
}
- info(
- `Routing upload of ${ file.name } through native server on port ${ port }`
- );
-
- // Forward the original request body — the file plus every sibling field
- // (`post`, additionalData) — and the original query string (e.g. `?_embed`)
- // so the native server can relay them to WordPress unchanged. Rebuilding the
- // body with only `file` would drop the post association and additionalData.
+ // Native code relays the upload with the original query string (e.g.
+ // `?_embed`) and every sibling field (`post`, additionalData) — dropping
+ // either would break the post association or the response's shape.
const query = requestQuery( options.path );
+ const upload = transport.schemeBase
+ ? schemeUpload( options, file, query, transport.schemeBase, next )
+ : loopbackUpload( options, file, query, transport );
+
// Use the two-argument form of `.then()` so the rejection handler catches
- // *only* a connection-level failure of the `fetch()` itself — not errors
- // thrown while handling a response (those must surface as real failures).
+ // *only* a failure to reach native code — not errors thrown while handling
+ // a response (those must surface as real failures).
+ return upload.then(
+ ( outcome ) =>
+ outcome.fallback ??
+ relayUploadResponse( outcome.response, options ),
+ ( connectionError ) =>
+ rejectUnreachableUpload( connectionError, options )
+ );
+}
+
+/**
+ * Sends an upload to Android's loopback upload server, as one `multipart`
+ * request carrying the original `FormData`.
+ *
+ * @param {Object} options The api-fetch options.
+ * @param {File} file The file being uploaded.
+ * @param {string} query The request's query string.
+ * @param {NativeUploadTransport} transport The loopback server's port and token.
+ * @return {Promise<{response: Response}>} The server's response.
+ */
+function loopbackUpload( options, file, query, { port, token } ) {
+ info(
+ `Routing upload of ${ file.name } through native server on port ${ port }`
+ );
return fetch( `http://localhost:${ port }/upload${ query }`, {
method: 'POST',
headers: {
@@ -290,124 +352,290 @@ function nativeMediaUpload( options, port, token ) {
},
body: options.body,
signal: options.signal,
- } ).then(
- ( response ) => {
- // `parse: false` asks for raw `Response` semantics. Core's media
- // upload middleware runs above this one and makes exactly that
- // request so it can read `x-wp-upload-attachment-id` off a failed
- // upload and retry `post-process`. Honor it by resolving or
- // rejecting with the `Response` itself, leaving the parsing (and
- // the recovery decision) to that middleware — parsing here would
- // hide the header and turn a recoverable upload into a permanent
- // failure.
- if ( options.parse === false ) {
- if ( ! response.ok ) {
- // A handoff to core's post-process retry, not an outcome —
- // core reads `x-wp-upload-attachment-id` off this response and
- // may still recover. Stay silent (as `nativeMediaDelete` does)
- // rather than reporting a failure that hasn't happened yet.
- return Promise.reject( response );
- }
- return response;
- }
+ } ).then( ( response ) => ( { response } ) );
+}
- // The native server relays WordPress's response verbatim. On a
- // non-2xx, mirror @wordpress/api-fetch: reject with the parsed WP
- // error body ({ code, message, data }) so @wordpress/media-utils
- // surfaces WordPress's real message. On success, return WordPress's
- // attachment object unchanged so every consumer behaves exactly as
- // it would for a non-native upload.
- if ( ! response.ok ) {
- return response
- .json()
- .catch( () => {
- // An abort during the body read rejects json() too; surface
- // the cancellation, not an "invalid response" error.
- if ( options.signal?.aborted ) {
- throw uploadAbortError( options.signal );
- }
- return invalidUploadResponseError();
- } )
- .then( ( body ) => {
- logError( 'Native upload failed', body );
- // Throw the parsed body verbatim, even if it isn't the usual
- // WordPress `{ code, message, data }` shape. This is
- // deliberate: it mirrors `@wordpress/api-fetch`'s
- // `parseAndThrowError`, so a native-relayed error reaches
- // consumers identically to a direct upload's. We intentionally
- // don't reshape or second-guess a non-standard error body.
- throw body;
- } );
- }
- // A 2xx with a non-JSON body (e.g. an HTML error page injected by an
- // intermediary) rejects json(); normalize it the same way as the
- // non-ok path rather than surfacing a raw SyntaxError.
- return response.json().catch( () => {
- // An abort during the body read rejects json(); surface the
- // cancellation rather than an "invalid response" error notice.
+/**
+ * Sends an upload to iOS's native upload scheme.
+ *
+ * The file goes in chunks, each an `ArrayBuffer`: WebKit hands a URL scheme
+ * handler only bodies it has buffered, and drops a `Blob` body — including a
+ * `FormData` that holds one — without an error. Chunking also keeps at most one
+ * chunk of the file in the page's memory at a time.
+ *
+ * A failure before `finish` means WordPress never saw the file, so the upload
+ * falls back to the web view's own path rather than failing: nothing can be
+ * duplicated. From `finish` on, native code may already have sent the file, so a
+ * failure there is reported, not retried.
+ *
+ * @param {Object} options The api-fetch options.
+ * @param {File} file The file being uploaded.
+ * @param {string} query The request's query string.
+ * @param {string} schemeBase The native upload scheme's base URL.
+ * @param {(options: Object) => Promise} next The next middleware, for the fallback.
+ * @return {Promise<{response?: Response, fallback?: Promise}>} The outcome.
+ */
+async function schemeUpload( options, file, query, schemeBase, next ) {
+ const { signal } = options;
+
+ info( `Routing upload of ${ file.name } through the native upload scheme` );
+
+ let sessionId = null;
+ try {
+ sessionId = await beginNativeUpload( schemeBase, file, signal );
+ for (
+ let offset = 0;
+ offset < file.size;
+ offset += NATIVE_UPLOAD_CHUNK_SIZE
+ ) {
+ const chunk = await file
+ .slice( offset, offset + NATIVE_UPLOAD_CHUNK_SIZE )
+ .arrayBuffer();
+ await expectOk(
+ fetch(
+ `${ schemeBase }/sessions/${ sessionId }/chunks?offset=${ offset }`,
+ { method: 'POST', body: chunk, signal }
+ )
+ );
+ }
+ } catch ( sendError ) {
+ if ( sessionId ) {
+ cancelNativeUpload( schemeBase, sessionId );
+ }
+ if ( signal?.aborted ) {
+ throw sendError;
+ }
+ warn(
+ 'Native upload unavailable; uploading through the web view instead',
+ sendError
+ );
+ return { fallback: next( options ) };
+ }
+
+ return {
+ response: await finishNativeUpload(
+ schemeBase,
+ sessionId,
+ uploadFields( options.body ),
+ query,
+ signal
+ ),
+ };
+}
+
+/**
+ * Starts a native upload session for `file` and returns its ID.
+ *
+ * @param {string} schemeBase The native upload scheme's base URL.
+ * @param {File} file The file to upload.
+ * @param {?AbortSignal} signal Cancels the request.
+ * @return {Promise} The session ID.
+ */
+async function beginNativeUpload( schemeBase, file, signal ) {
+ const response = await expectOk(
+ fetch( `${ schemeBase }/sessions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify( {
+ filename: file.name,
+ mimeType: file.type || 'application/octet-stream',
+ size: file.size,
+ } ),
+ signal,
+ } )
+ );
+ const { id } = await response.json();
+ return id;
+}
+
+/**
+ * Asks native code to upload a session's file to WordPress, and returns
+ * WordPress's response.
+ *
+ * @param {string} schemeBase The native upload scheme's base URL.
+ * @param {string} sessionId The session to finish.
+ * @param {Array} fields The upload's form fields.
+ * @param {string} query The request's query string.
+ * @param {?AbortSignal} signal Cancels the upload.
+ * @return {Promise} WordPress's response, relayed.
+ */
+function finishNativeUpload( schemeBase, sessionId, fields, query, signal ) {
+ return fetch( `${ schemeBase }/sessions/${ sessionId }/finish`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify( { fields, query } ),
+ signal,
+ } );
+}
+
+/**
+ * Abandons a native upload session, best effort: native code also sweeps idle
+ * sessions, so a cancel that doesn't arrive only delays the cleanup.
+ *
+ * @param {string} schemeBase The native upload scheme's base URL.
+ * @param {string} sessionId The session to abandon.
+ */
+function cancelNativeUpload( schemeBase, sessionId ) {
+ fetch( `${ schemeBase }/sessions/${ sessionId }/cancel`, {
+ method: 'POST',
+ } ).catch( () => {} );
+}
+
+/**
+ * Resolves with the response when it is a 2xx, and rejects otherwise.
+ *
+ * @param {Promise} request The request.
+ * @return {Promise} The successful response.
+ */
+async function expectOk( request ) {
+ const response = await request;
+ if ( ! response.ok ) {
+ throw new Error(
+ `Native upload request failed with status ${ response.status }`
+ );
+ }
+ return response;
+}
+
+/**
+ * The upload's text fields — everything in the `FormData` except the file — in
+ * order, as `{ name, value }` pairs, so repeated names (e.g. `field[]`) survive.
+ *
+ * @param {FormData} formData The upload's body.
+ * @return {Array<{name: string, value: string}>} The fields.
+ */
+function uploadFields( formData ) {
+ const fields = [];
+ for ( const [ name, value ] of formData.entries() ) {
+ if ( typeof value === 'string' ) {
+ fields.push( { name, value } );
+ }
+ }
+ return fields;
+}
+
+/**
+ * Turns native code's relay of WordPress's response into what the caller asked
+ * for.
+ *
+ * @param {Response} response WordPress's response, relayed.
+ * @param {Object} options The api-fetch options.
+ * @return {Promise|Response} The response or its parsed body.
+ */
+function relayUploadResponse( response, options ) {
+ // `parse: false` asks for raw `Response` semantics. Core's media
+ // upload middleware runs above this one and makes exactly that
+ // request so it can read `x-wp-upload-attachment-id` off a failed
+ // upload and retry `post-process`. Honor it by resolving or
+ // rejecting with the `Response` itself, leaving the parsing (and
+ // the recovery decision) to that middleware — parsing here would
+ // hide the header and turn a recoverable upload into a permanent
+ // failure.
+ if ( options.parse === false ) {
+ if ( ! response.ok ) {
+ // A handoff to core's post-process retry, not an outcome —
+ // core reads `x-wp-upload-attachment-id` off this response and
+ // may still recover. Stay silent (as `nativeMediaDelete` does)
+ // rather than reporting a failure that hasn't happened yet.
+ return Promise.reject( response );
+ }
+ return response;
+ }
+
+ // Native code relays WordPress's response verbatim. On a
+ // non-2xx, mirror @wordpress/api-fetch: reject with the parsed WP
+ // error body ({ code, message, data }) so @wordpress/media-utils
+ // surfaces WordPress's real message. On success, return WordPress's
+ // attachment object unchanged so every consumer behaves exactly as
+ // it would for a non-native upload.
+ if ( ! response.ok ) {
+ return response
+ .json()
+ .catch( () => {
+ // An abort during the body read rejects json() too; surface
+ // the cancellation, not an "invalid response" error.
if ( options.signal?.aborted ) {
throw uploadAbortError( options.signal );
}
- const error = invalidUploadResponseError();
- logError( 'Native upload returned an invalid response', error );
- throw error;
+ return invalidUploadResponseError();
+ } )
+ .then( ( body ) => {
+ logError( 'Native upload failed', body );
+ // Throw the parsed body verbatim, even if it isn't the usual
+ // WordPress `{ code, message, data }` shape. This is
+ // deliberate: it mirrors `@wordpress/api-fetch`'s
+ // `parseAndThrowError`, so a native-relayed error reaches
+ // consumers identically to a direct upload's. We intentionally
+ // don't reshape or second-guess a non-standard error body.
+ throw body;
} );
- },
- ( connectionError ) => {
- // A caller-initiated cancellation must propagate as the cancellation,
- // never be retried. Detect it via `signal.aborted` — the cancellation
- // *state* — rather than `connectionError.name === 'AbortError'`: the
- // state check also catches `AbortSignal.timeout()` (which rejects with
- // a TimeoutError, not an AbortError) and custom abort reasons, which a
- // name match would miss and wrongly fall back on. Rethrow the signal's
- // `reason` (the canonical abort error), not `connectionError`: if a
- // network failure and the abort race, `fetch` can reject with a network
- // TypeError even though the signal aborted, and rethrowing that would
- // make upstream treat a cancelled upload as a real failure — surfacing
- // a spurious error notice instead of a silent cancel.
- if ( options.signal?.aborted ) {
- throw uploadAbortError( options.signal );
- }
- // Otherwise the loopback upload server is unreachable at the transport
- // layer. We deliberately do NOT fall back to a direct re-upload:
- // reachability is gated proactively upstream — this middleware's guard
- // skips the native path when no port is advertised, and the native side
- // only advertises a port the WebView can actually reach (server running
- // + cleartext-to-localhost permitted, cleared on stop). So reaching here
- // means the server died out-of-band after a valid start; retrying a
- // non-idempotent POST /wp/v2/media could duplicate the attachment if the
- // native server had already relayed it to WordPress.
- logError(
- 'Native upload failed at the transport layer',
- connectionError
- );
- // Normalize to the same `{ code, message }` shape
- // `@wordpress/api-fetch`'s default handler produces for a failed fetch,
- // so a native-upload transport failure surfaces to consumers (which key
- // off `error.code` and show `error.message`) exactly like a direct
- // upload's would — not as a raw, code-less TypeError with an
- // untranslated message. Same codes and strings as api-fetch, so the
- // existing translations apply.
- if ( ! globalThis.navigator.onLine ) {
- throw {
- code: 'offline_error',
- message: __(
- 'Unable to connect. Please check your Internet connection.'
- ),
- };
- }
- throw {
- code: 'fetch_error',
- message: __(
- 'Could not get a valid response from the server.'
- ),
- };
+ }
+ // A 2xx with a non-JSON body (e.g. an HTML error page injected by an
+ // intermediary) rejects json(); normalize it the same way as the
+ // non-ok path rather than surfacing a raw SyntaxError.
+ return response.json().catch( () => {
+ // An abort during the body read rejects json(); surface the
+ // cancellation rather than an "invalid response" error notice.
+ if ( options.signal?.aborted ) {
+ throw uploadAbortError( options.signal );
}
- );
+ const error = invalidUploadResponseError();
+ logError( 'Native upload returned an invalid response', error );
+ throw error;
+ } );
+}
+
+/**
+ * Rejects an upload that could not reach native code, or whose relay failed.
+ *
+ * @param {unknown} connectionError What the request rejected with.
+ * @param {Object} options The api-fetch options.
+ * @return {never} Always throws.
+ */
+function rejectUnreachableUpload( connectionError, options ) {
+ // A caller-initiated cancellation must propagate as the cancellation,
+ // never be retried. Detect it via `signal.aborted` — the cancellation
+ // *state* — rather than `connectionError.name === 'AbortError'`: the
+ // state check also catches `AbortSignal.timeout()` (which rejects with
+ // a TimeoutError, not an AbortError) and custom abort reasons, which a
+ // name match would miss and wrongly fall back on. Rethrow the signal's
+ // `reason` (the canonical abort error), not `connectionError`: if a
+ // network failure and the abort race, `fetch` can reject with a network
+ // TypeError even though the signal aborted, and rethrowing that would
+ // make upstream treat a cancelled upload as a real failure — surfacing
+ // a spurious error notice instead of a silent cancel.
+ if ( options.signal?.aborted ) {
+ throw uploadAbortError( options.signal );
+ }
+ // Otherwise native code could not be reached, or dropped the request,
+ // once it may already have sent the file to WordPress. We deliberately do
+ // NOT fall back to a direct re-upload: retrying a non-idempotent
+ // POST /wp/v2/media could duplicate the attachment. (The scheme transport
+ // falls back itself, earlier, while that is still safe.)
+ logError( 'Native upload failed at the transport layer', connectionError );
+ // Normalize to the same `{ code, message }` shape
+ // `@wordpress/api-fetch`'s default handler produces for a failed fetch,
+ // so a native-upload transport failure surfaces to consumers (which key
+ // off `error.code` and show `error.message`) exactly like a direct
+ // upload's would — not as a raw, code-less TypeError with an
+ // untranslated message. Same codes and strings as api-fetch, so the
+ // existing translations apply.
+ if ( ! globalThis.navigator.onLine ) {
+ throw {
+ code: 'offline_error',
+ message: __(
+ 'Unable to connect. Please check your Internet connection.'
+ ),
+ };
+ }
+ throw {
+ code: 'fetch_error',
+ message: __( 'Could not get a valid response from the server.' ),
+ };
}
/**
- * Routes a media attachment deletion through the native upload server.
+ * Routes a media attachment deletion through native code.
*
* Returns `null` when the request is not a media deletion, so the caller falls
* through to its normal handling.
@@ -417,19 +645,18 @@ function nativeMediaUpload( options, port, token ) {
* cross-origin editor: `@wordpress/api-fetch` tunnels `DELETE` as a `POST`
* carrying `X-HTTP-Method-Override`, and core's `rest_allowed_cors_headers`
* does not list that header, so the browser blocks it at preflight and the
- * orphan survives. Relaying through the loopback server — which sets its own
- * CORS policy — is what lets the cleanup complete.
+ * orphan survives. Relaying through native code — which sets its own CORS
+ * policy — is what lets the cleanup complete.
*
* This runs before `X-HTTP-Method-Override` exists: api-fetch's `httpV1`
* middleware adds it further down the chain, so the method here is still a
* plain `DELETE`.
*
- * @param {Object} options The api-fetch options.
- * @param {number} port The native upload server port.
- * @param {string} token The native upload server bearer token.
+ * @param {Object} options The api-fetch options.
+ * @param {NativeUploadTransport} transport How to reach native code.
* @return {?Promise} The relayed deletion, or `null` if not applicable.
*/
-function nativeMediaDelete( options, port, token ) {
+function nativeMediaDelete( options, transport ) {
if ( options.method?.toUpperCase() !== 'DELETE' || ! options.path ) {
return null;
}
@@ -443,17 +670,28 @@ function nativeMediaDelete( options, port, token ) {
const query = requestQuery( options.path );
info(
- `Routing deletion of attachment ${ attachmentId } through native server`
+ `Routing deletion of attachment ${ attachmentId } through native code`
);
- return fetch(
- `http://localhost:${ port }/media/${ attachmentId }${ query }`,
- {
- method: 'DELETE',
- headers: { 'Relay-Authorization': `Bearer ${ token }` },
- signal: options.signal,
- }
- ).then(
+ const request = transport.schemeBase
+ ? fetch( `${ transport.schemeBase }/media/${ attachmentId }/delete`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify( { query } ),
+ signal: options.signal,
+ } )
+ : fetch(
+ `http://localhost:${ transport.port }/media/${ attachmentId }${ query }`,
+ {
+ method: 'DELETE',
+ headers: {
+ 'Relay-Authorization': `Bearer ${ transport.token }`,
+ },
+ signal: options.signal,
+ }
+ );
+
+ return request.then(
( response ) => {
if ( options.parse === false ) {
if ( ! response.ok ) {
@@ -503,11 +741,9 @@ function nativeMediaDelete( options, port, token ) {
* The query component of a request path, including the leading `?`, or an empty
* string when there is no query.
*
- * Mirrors the `query` accessors on the native request types (`HttpRequest` on
- * Android, `ParsedHTTPRequest` on iOS): the split is on the first `?`, and a
- * bare trailing `?` carries no parameters so it yields an empty string. Keeping
- * the three in agreement means the value can be appended to an upstream URL
- * unconditionally, whichever side derived it.
+ * Mirrors Android's `HttpRequest.query`: the split is on the first `?`, and a
+ * bare trailing `?` carries no parameters so it yields an empty string. Native
+ * code appends the value to the WordPress URL unconditionally.
*
* @param {string} path The request path, e.g. `/wp/v2/media?_embed`.
* @return {string} The query, e.g. `?_embed`, or `''`.
@@ -616,56 +852,38 @@ function mediaPermissionsMiddleware( options, next ) {
* Remove the wrapping element from the oEmbed response, as it breaks
* Gutenberg's sizing styles.
*
+ * A failed request is left to reject. Core stores `false` for it, which the
+ * Embed block shows as "could not be embedded" and which blocks that poll for a
+ * preview — VideoPress, while WordPress.com is still processing an upload —
+ * take as the cue to ask again. Resolving with a link in its place reads as a
+ * finished preview, so those blocks stop asking and render the link.
+ *
* @type {APIFetchMiddleware}
*
* @todo Hoist this host-specific logic to the host app.
*/
function transformOEmbedApiResponse( options, next ) {
if ( options.path && options.path.indexOf( 'oembed' ) !== -1 ) {
- const url = getQueryArg( options.path, 'url' );
- const response = next( options, next );
-
- /**
- * Creates an embed response emulating core's fallback link.
- */
- function createFallbackResponse() {
- const link = document.createElement( 'a' );
- link.href = url;
- link.innerText = url;
- return {
- html: link.outerHTML,
- type: 'rich',
- provider_name: 'Embed',
- };
- }
+ return next( options, next ).then( ( data ) => {
+ if ( data?.html ) {
+ /**
+ * Removes wrappers from YouTube, Vimeo, Dailymotion, TED block, e.g.
+ * ,