diff --git a/lib/requester.js b/lib/requester.js index 07b777f3..efb13400 100644 --- a/lib/requester.js +++ b/lib/requester.js @@ -420,11 +420,14 @@ function multipartRequester(request) { const operation = this; const operationBoundary = operation.multipartBoundary; - const multipartStream = new Multipart((operationBoundary == null) ? - mlutil.multipartBoundary : operationBoundary); + const boundary = (operationBoundary == null) ? mlutil.multipartBoundary : operationBoundary; + const CRNL = '\r\n'; const requestPartsProvider = operation.requestPartsProvider; - if(operation.bindingParam) { + if (operation.bindingParam) { + // bindingParam path: write boundary+headers as Buffers, pipe form-data stream directly. + // multipart-stream's addPart() writes headers synchronously to a PassThrough before piping, + // triggering the same Node.js v26+ 'readable' regression as the requestPartList path. const form = new formData(); const bindingParam = operation.bindingParam; const query = bindingParam.query; @@ -468,41 +471,79 @@ function multipartRequester(request) { form.append('metadata', JSON.stringify(metadata), {contentType: 'application/json', filename: 'metadata.json'}); } - multipartStream.add({ - headers: { - 'Content-Type': 'multipart/form-data; boundary=' + mlutil.multipartBoundary, - Accept: 'application/json', - }, - body: form, - }); + // All bindingParam content (query AST, binding, attachments, metadata) is synchronous + // JSON so getBuffer() collects it without streaming, avoiding the Node.js v26+ + // sandwich-stream 'readable' regression entirely. + const formBuffer = form.getBuffer(); + request.write(Buffer.from('--' + boundary + CRNL)); + request.write(Buffer.from('Content-Type: multipart/form-data; boundary=' + mlutil.multipartBoundary + CRNL)); + request.write(Buffer.from('Accept: application/json' + CRNL)); + request.write(Buffer.from(CRNL)); + request.write(formBuffer); + request.write(Buffer.from(CRNL + '--' + boundary + '--')); + request.end(); } else if (typeof requestPartsProvider === 'function') { + // requestPartsProvider path: use multipart-stream (provider controls parts) + const multipartStream = new Multipart(boundary); requestPartsProvider.call(operation, multipartStream); + multipartStream.on('error', err => request.destroy(err)); + multipartStream.pipe(request); } else { const parts = operation.requestPartList; - if (Array.isArray(parts)) { - const partsLen = parts.length; - operation.logger.debug('writing %s parts', partsLen); - for (let i=0; i < partsLen; i++) { + // Stream-driven PassThroughs receive writes asynchronously (after listeners are + // attached), so multipart-stream works correctly for them on all Node.js versions. + // Only the all-synchronous case triggers the Node.js v26+ 'readable' regression. + const hasStreamContent = Array.isArray(parts) && + parts.some(p => p.content !== null && typeof p.content.pipe === 'function'); + + if (hasStreamContent) { + const multipartStream = new Multipart(boundary); + operation.logger.debug('writing %s parts', parts.length); + for (let i = 0; i < parts.length; i++) { const part = parts[i]; const headers = part.headers; const content = part.content; - if ((headers != null) && - (content != null)) { + if ((headers !== null) && (content !== null)) { operation.logger.debug('starting part %s', i); - multipartStream.addPart({ - headers: headers, - body: content - }); + multipartStream.addPart({ headers: headers, body: content }); operation.logger.debug('finished part %s', i); } else { operation.logger.debug('nothing to write for part %d', i); } } + multipartStream.on('error', err => request.destroy(err)); + multipartStream.pipe(request); } else { - operation.logger.debug('no part list to write'); + // All synchronous content: build body directly as Buffers to avoid + // sandwich-stream's 'readable' event regression on Node.js v26+. + const bufs = [Buffer.from('--' + boundary + CRNL)]; + let written = 0; + if (Array.isArray(parts)) { + operation.logger.debug('writing %s parts', parts.length); + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const headers = part.headers; + const content = part.content; + if ((headers != null) && (content != null)) { + if (written > 0) { bufs.push(Buffer.from(CRNL + '--' + boundary + CRNL)); } + operation.logger.debug('starting part %s', i); + Object.entries(headers).forEach(([k, v]) => bufs.push(Buffer.from(k + ': ' + v + CRNL))); + bufs.push(Buffer.from(CRNL)); + bufs.push(Buffer.isBuffer(content) ? content : Buffer.from(content)); + operation.logger.debug('finished part %s', i); + written++; + } else { + operation.logger.debug('nothing to write for part %d', i); + } + } + } else { + operation.logger.debug('no part list to write'); + } + bufs.push(Buffer.from(CRNL + '--' + boundary + '--')); + for (let i = 0; i < bufs.length - 1; i++) { request.write(bufs[i]); } + request.end(bufs[bufs.length - 1]); } } - multipartStream.pipe(request); } function chunkedRequester(request) { /*jshint validthis:true */ @@ -530,38 +571,48 @@ function chunkedMultipartRequester(request) { request.end(); } else { const operationBoundary = operation.multipartBoundary; + const boundary = (operationBoundary == null) ? mlutil.multipartBoundary : operationBoundary; + const CRNL = '\r\n'; - const multipartStream = new Multipart((operationBoundary == null) ? - mlutil.multipartBoundary : operationBoundary); - + // Write all non-stream (metadata) parts directly as Buffers, then pipe the + // streaming content part into the request. This avoids the sandwich-stream + // 'readable' event regression on Node.js v26+ where multi-chunk PassThrough + // streams are not fully drained, leaving the closing boundary unemitted. const partLast = requestDocument.length - 1; + request.write(Buffer.from('--' + boundary + CRNL)); + let written = 0; for (let i=0; i <= partLast; i++) { const part = requestDocument[i]; const headers = part.headers; if (i < partLast) { const content = part.content; - if ((headers != null) && - (content != null)) { - multipartStream.addPart({ - headers: headers, - body: mlutil.marshal(content, operation) - }); + if ((headers != null) && (content != null)) { + if (written > 0) { request.write(Buffer.from(CRNL + '--' + boundary + CRNL)); } + Object.entries(headers).forEach(([k, v]) => request.write(Buffer.from(k + ': ' + v + CRNL))); + request.write(Buffer.from(CRNL)); + const marshaledContent = mlutil.marshal(content, operation); + request.write(Buffer.isBuffer(marshaledContent) ? marshaledContent : Buffer.from(marshaledContent)); + written++; } else { operation.logger.debug('could not write metadata part'); } } else { if (headers != null) { - multipartStream.addPart({ - headers: headers, - body: requestWriter - }); + if (written > 0) { request.write(Buffer.from(CRNL + '--' + boundary + CRNL)); } + Object.entries(headers).forEach(([k, v]) => request.write(Buffer.from(k + ': ' + v + CRNL))); + request.write(Buffer.from(CRNL)); + requestWriter.on('error', err => request.destroy(err)); + requestWriter.pipe(request, { end: false }); + requestWriter.on('end', () => { + request.write(Buffer.from(CRNL + '--' + boundary + '--')); + request.end(); + }); } else { operation.logger.debug('could not write content part'); + request.end(); } } } - - multipartStream.pipe(request); } }