Skip to content

Commit 98bb864

Browse files
Copilotfarfromrefug
andcommitted
Implement streaming downloads for iOS GET requests
Agent-Logs-Url: https://github.com/nativescript-community/https/sessions/07e4da87-be33-46f4-872f-e397b5e6c049 Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com>
1 parent 56706fc commit 98bb864

File tree

3 files changed

+249
-15
lines changed

3 files changed

+249
-15
lines changed

packages/https/platforms/ios/src/AlamofireWrapper.swift

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,92 @@ public class AlamofireWrapper: NSObject {
350350

351351
// MARK: - Download Tasks
352352

353+
// Streaming download to temporary location (for deferred processing)
354+
// This downloads the response body to a temp file and returns the temp path
355+
// Allows inspecting headers before deciding what to do with the body
356+
@objc public func downloadToTemp(
357+
_ method: String,
358+
_ urlString: String,
359+
_ parameters: NSDictionary?,
360+
_ headers: NSDictionary?,
361+
_ progress: ((Progress) -> Void)?,
362+
_ completionHandler: @escaping (URLResponse?, String?, Error?) -> Void
363+
) -> URLSessionDownloadTask? {
364+
365+
guard let url = URL(string: urlString) else {
366+
let error = NSError(domain: "AlamofireWrapper", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])
367+
completionHandler(nil, nil, error)
368+
return nil
369+
}
370+
371+
var request: URLRequest
372+
do {
373+
request = try requestSerializer.createRequest(
374+
url: url,
375+
method: HTTPMethod(rawValue: method.uppercased()),
376+
parameters: nil,
377+
headers: headers
378+
)
379+
// Encode parameters into the request
380+
try requestSerializer.encodeParameters(parameters, into: &request, method: HTTPMethod(rawValue: method.uppercased()))
381+
} catch {
382+
completionHandler(nil, nil, error)
383+
return nil
384+
}
385+
386+
// Create destination closure that saves to a temp file
387+
let destination: DownloadRequest.Destination = { temporaryURL, response in
388+
// Create a unique temp file path
389+
let tempDir = FileManager.default.temporaryDirectory
390+
let tempFileName = UUID().uuidString
391+
let tempFileURL = tempDir.appendingPathComponent(tempFileName)
392+
393+
return (tempFileURL, [.removePreviousFile, .createIntermediateDirectories])
394+
}
395+
396+
var downloadRequest = session.download(request, to: destination)
397+
398+
// Apply server trust evaluation if security policy is set
399+
if let secPolicy = securityPolicy, let host = url.host {
400+
downloadRequest = downloadRequest.validate { _, response, _ in
401+
guard let serverTrust = response.serverTrust else {
402+
return .failure(AFError.serverTrustEvaluationFailed(reason: .noServerTrust))
403+
}
404+
do {
405+
try secPolicy.evaluate(serverTrust, forHost: host)
406+
return .success(Void())
407+
} catch {
408+
return .failure(error)
409+
}
410+
}
411+
}
412+
413+
// Download progress
414+
if let progress = progress {
415+
downloadRequest = downloadRequest.downloadProgress { progressInfo in
416+
progress(progressInfo)
417+
}
418+
}
419+
420+
// Response handling
421+
downloadRequest.response(queue: .main) { response in
422+
if let error = response.error {
423+
completionHandler(response.response, nil, error)
424+
return
425+
}
426+
427+
// Return the temp file path on success
428+
if let tempFileURL = response.fileURL {
429+
completionHandler(response.response, tempFileURL.path, nil)
430+
} else {
431+
let error = NSError(domain: "AlamofireWrapper", code: -1, userInfo: [NSLocalizedDescriptionKey: "No file URL in download response"])
432+
completionHandler(response.response, nil, error)
433+
}
434+
}
435+
436+
return downloadRequest.task as? URLSessionDownloadTask
437+
}
438+
353439
// Clean API: Download file with streaming to disk (optimized, no memory loading)
354440
@objc public func downloadToFile(
355441
_ urlString: String,

src/https/request.ios.ts

Lines changed: 146 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -113,18 +113,66 @@ function createNSRequest(url: string): NSMutableURLRequest {
113113

114114
class HttpsResponseLegacy implements IHttpsResponseLegacy {
115115
// private callback?: com.nativescript.https.OkhttpResponse.OkHttpResponseAsyncCallback;
116+
private tempFilePath?: string;
117+
116118
constructor(
117119
private data: NSDictionary<string, any> & NSData & NSArray<any>,
118120
public contentLength,
119-
private url: string
120-
) {}
121+
private url: string,
122+
tempFilePath?: string
123+
) {
124+
this.tempFilePath = tempFilePath;
125+
}
126+
127+
// Helper to ensure data is loaded from temp file if needed
128+
private ensureDataLoaded(): boolean {
129+
// If we have data already, we're good
130+
if (this.data) {
131+
return true;
132+
}
133+
134+
// If we have a temp file, load it into memory
135+
if (this.tempFilePath) {
136+
try {
137+
this.data = NSData.dataWithContentsOfFile(this.tempFilePath) as any;
138+
return this.data != null;
139+
} catch (e) {
140+
console.error('Failed to load data from temp file:', e);
141+
return false;
142+
}
143+
}
144+
145+
return false;
146+
}
147+
148+
// Helper to get temp file path or create from data
149+
private getTempFilePath(): string | null {
150+
if (this.tempFilePath) {
151+
return this.tempFilePath;
152+
}
153+
154+
// If we have data but no temp file, create a temp file
155+
if (this.data && this.data instanceof NSData) {
156+
const tempDir = NSTemporaryDirectory();
157+
const tempFileName = NSUUID.UUID().UUIDString;
158+
const tempPath = tempDir + tempFileName;
159+
const success = this.data.writeToFileAtomically(tempPath, true);
160+
if (success) {
161+
this.tempFilePath = tempPath;
162+
return tempPath;
163+
}
164+
}
165+
166+
return null;
167+
}
168+
121169
toArrayBufferAsync(): Promise<ArrayBuffer> {
122170
throw new Error('Method not implemented.');
123171
}
124172

125173
arrayBuffer: ArrayBuffer;
126174
toArrayBuffer() {
127-
if (!this.data) {
175+
if (!this.ensureDataLoaded()) {
128176
return null;
129177
}
130178
if (this.arrayBuffer) {
@@ -139,7 +187,7 @@ class HttpsResponseLegacy implements IHttpsResponseLegacy {
139187
}
140188
stringResponse: string;
141189
toString(encoding?: any) {
142-
if (!this.data) {
190+
if (!this.ensureDataLoaded()) {
143191
return null;
144192
}
145193
if (this.stringResponse) {
@@ -168,7 +216,7 @@ class HttpsResponseLegacy implements IHttpsResponseLegacy {
168216
}
169217
jsonResponse: any;
170218
toJSON<T>(encoding?: any) {
171-
if (!this.data) {
219+
if (!this.ensureDataLoaded()) {
172220
return null;
173221
}
174222
if (this.jsonResponse) {
@@ -192,7 +240,7 @@ class HttpsResponseLegacy implements IHttpsResponseLegacy {
192240
}
193241
imageSource: ImageSource;
194242
async toImage(): Promise<ImageSource> {
195-
if (!this.data) {
243+
if (!this.ensureDataLoaded()) {
196244
return Promise.resolve(null);
197245
}
198246
if (this.imageSource) {
@@ -212,30 +260,58 @@ class HttpsResponseLegacy implements IHttpsResponseLegacy {
212260
}
213261
file: File;
214262
async toFile(destinationFilePath?: string): Promise<File> {
215-
if (!this.data) {
216-
return Promise.resolve(null);
217-
}
218263
if (this.file) {
219264
return Promise.resolve(this.file);
220265
}
266+
221267
const r = await new Promise<File>((resolve, reject) => {
222268
if (!destinationFilePath) {
223269
destinationFilePath = getFilenameFromUrl(this.url);
224270
}
225-
if (this.data instanceof NSData) {
226-
// ensure destination path exists by creating any missing parent directories
271+
272+
// If we have a temp file, move it to destination (efficient, no memory copy)
273+
if (this.tempFilePath) {
274+
try {
275+
const fileManager = NSFileManager.defaultManager;
276+
const destURL = NSURL.fileURLWithPath(destinationFilePath);
277+
const tempURL = NSURL.fileURLWithPath(this.tempFilePath);
278+
279+
// Create parent directory if needed
280+
const parentDir = destURL.URLByDeletingLastPathComponent;
281+
fileManager.createDirectoryAtURLWithIntermediateDirectoriesAttributesError(parentDir, true, null);
282+
283+
// Remove destination if it exists
284+
if (fileManager.fileExistsAtPath(destinationFilePath)) {
285+
fileManager.removeItemAtPathError(destinationFilePath);
286+
}
287+
288+
// Move temp file to destination
289+
const success = fileManager.moveItemAtURLToURLError(tempURL, destURL);
290+
if (success) {
291+
// Clear temp path since file has been moved
292+
this.tempFilePath = null;
293+
resolve(File.fromPath(destinationFilePath));
294+
} else {
295+
reject(new Error(`Failed to move temp file to: ${destinationFilePath}`));
296+
}
297+
} catch (e) {
298+
reject(new Error(`Cannot save file with path: ${destinationFilePath}. ${e}`));
299+
}
300+
}
301+
// Fallback: if we have data in memory, write it
302+
else if (this.ensureDataLoaded() && this.data instanceof NSData) {
227303
const file = File.fromPath(destinationFilePath);
228-
229304
const result = this.data.writeToFileAtomically(destinationFilePath, true);
230305
if (result) {
231306
resolve(file);
232307
} else {
233308
reject(new Error(`Cannot save file with path: ${destinationFilePath}.`));
234309
}
235310
} else {
236-
reject(new Error(`Cannot save file with path: ${destinationFilePath}.`));
311+
reject(new Error(`No data available to save to file: ${destinationFilePath}.`));
237312
}
238313
});
314+
239315
this.file = r;
240316
return r;
241317
}
@@ -549,8 +625,63 @@ export function createRequest(opts: HttpsRequestOptions, useLegacy: boolean = tr
549625
} else if (typeof opts.content === 'string') {
550626
dict = NSJSONSerialization.JSONObjectWithDataOptionsError(NSString.stringWithString(opts.content).dataUsingEncoding(NSUTF8StringEncoding), 0 as any);
551627
}
552-
task = manager.request(opts.method, opts.url, dict, headers, progress, progress, success, failure);
553-
task.resume();
628+
629+
// For GET requests, use streaming download to temp file (memory efficient)
630+
if (opts.method === 'GET') {
631+
const downloadTask = manager.downloadToTemp(
632+
opts.method,
633+
opts.url,
634+
dict,
635+
headers,
636+
progress,
637+
(response: NSURLResponse, tempFilePath: string, error: NSError) => {
638+
clearRunningRequest();
639+
if (error) {
640+
// Convert download task to data task for failure handling
641+
const dataTask = (task as any) as NSURLSessionDataTask;
642+
failure(dataTask, error);
643+
return;
644+
}
645+
646+
const httpResponse = response as NSHTTPURLResponse;
647+
const contentLength = httpResponse?.expectedContentLength || 0;
648+
649+
// Create response with temp file path (no data loaded in memory yet)
650+
const content = useLegacy
651+
? new HttpsResponseLegacy(null, contentLength, opts.url, tempFilePath)
652+
: tempFilePath;
653+
654+
let getHeaders = () => ({});
655+
const sendi = {
656+
content,
657+
contentLength,
658+
get headers() {
659+
return getHeaders();
660+
}
661+
} as any as HttpsResponse;
662+
663+
if (!Utils.isNullOrUndefined(httpResponse)) {
664+
sendi.statusCode = httpResponse.statusCode;
665+
getHeaders = function () {
666+
const dict = httpResponse.allHeaderFields;
667+
if (dict) {
668+
const headers = {};
669+
dict.enumerateKeysAndObjectsUsingBlock((k, v) => (headers[k] = v));
670+
return headers;
671+
}
672+
return null;
673+
};
674+
}
675+
resolve(sendi);
676+
}
677+
);
678+
679+
task = downloadTask as any;
680+
} else {
681+
// For non-GET requests, use regular request (loads into memory)
682+
task = manager.request(opts.method, opts.url, dict, headers, progress, progress, success, failure);
683+
task.resume();
684+
}
554685
}
555686
if (task && tag) {
556687
runningRequests[tag] = task;

src/https/typings/objc!AlamofireWrapper.d.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,23 @@ declare class AlamofireWrapper extends NSObject {
4646
progress: (progress: NSProgress) => void,
4747
completionHandler: (response: NSURLResponse, responseObject: any, error: NSError) => void
4848
): NSURLSessionDataTask;
49+
50+
downloadToTemp(
51+
method: string,
52+
urlString: string,
53+
parameters: NSDictionary<string, any>,
54+
headers: NSDictionary<string, any>,
55+
progress: (progress: NSProgress) => void,
56+
completionHandler: (response: NSURLResponse, tempFilePath: string, error: NSError) => void
57+
): NSURLSessionDownloadTask;
58+
59+
downloadToFile(
60+
urlString: string,
61+
destinationPath: string,
62+
headers: NSDictionary<string, any>,
63+
progress: (progress: NSProgress) => void,
64+
completionHandler: (response: NSURLResponse, filePath: string, error: NSError) => void
65+
): NSURLSessionDownloadTask;
4966
}
5067

5168
declare class RequestSerializer extends NSObject {

0 commit comments

Comments
 (0)