@whatwg-node/node-fetch
Version:
Fetch API implementation for Node
400 lines (399 loc) • 15.5 kB
JavaScript
import { Buffer } from 'node:buffer';
import { once } from 'node:events';
import { Readable } from 'node:stream';
import { finished } from 'node:stream/promises';
import { fakeRejectPromise, handleMaybePromise } from '@whatwg-node/promise-helpers';
import { fakePromise } from './utils.js';
function createController(desiredSize, readable) {
let chunks = [];
let _closed = false;
let flushed = false;
return {
desiredSize,
enqueue(chunk) {
const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
if (!flushed) {
chunks.push(buf);
}
else {
readable.push(buf);
}
},
close() {
if (chunks.length > 0) {
this._flush();
}
readable.push(null);
_closed = true;
},
error(error) {
if (chunks.length > 0) {
this._flush();
}
readable.destroy(error);
},
get _closed() {
return _closed;
},
_flush() {
flushed = true;
if (chunks.length > 0) {
const concatenated = chunks.length > 1 ? Buffer.concat(chunks) : chunks[0];
readable.push(concatenated);
chunks = [];
}
},
};
}
function isNodeReadable(obj) {
return obj?.read != null;
}
function isReadableStream(obj) {
return obj?.getReader != null;
}
/** In-flight cancel(reason); keyed by underlying Readable (not stored on it). */
const pendingCancelReasons = new WeakMap();
const noop = () => { };
function isExpectedCancelError(err, reason) {
const code = err?.code;
return (Object.is(err, reason) ||
code === 'ERR_STREAM_PREMATURE_CLOSE' ||
err?.message === 'Premature close');
}
export class PonyfillReadableStream {
readable;
constructor(underlyingSource) {
if (underlyingSource instanceof PonyfillReadableStream && underlyingSource.readable != null) {
this.readable = underlyingSource.readable;
}
else if (isNodeReadable(underlyingSource)) {
this.readable = underlyingSource;
}
else if (isReadableStream(underlyingSource)) {
this.readable = Readable.fromWeb(underlyingSource);
}
else {
let started = false;
let ongoing = false;
const handleStart = (desiredSize) => {
if (!started) {
const controller = createController(desiredSize, this.readable);
started = true;
return handleMaybePromise(() => underlyingSource?.start?.(controller), () => {
controller._flush();
if (controller._closed) {
return false;
}
return true;
});
}
return true;
};
const readImpl = (desiredSize) => {
return handleMaybePromise(() => handleStart(desiredSize), shouldContinue => {
if (!shouldContinue) {
return;
}
const controller = createController(desiredSize, this.readable);
return handleMaybePromise(() => underlyingSource?.pull?.(controller), () => {
controller._flush();
ongoing = false;
});
});
};
this.readable = new Readable({
read(desiredSize) {
if (ongoing) {
return;
}
ongoing = true;
return readImpl(desiredSize);
},
destroy(err, callback) {
// cancel() sets a pending marker and destroy()s without an error for this path.
// A racing destroy(otherErr) must still surface as a real failure.
const pending = pendingCancelReasons.get(this);
const fromCancel = pending != null && err == null;
const cancelReason = fromCancel ? pending.value : undefined;
if (fromCancel) {
pendingCancelReasons.delete(this);
}
if (underlyingSource?.cancel) {
try {
const res$ = underlyingSource.cancel(fromCancel ? cancelReason : err);
if (res$?.then) {
return res$.then(() => {
// Preserve real destroy(err) after the cancel hook runs.
callback(fromCancel ? null : (err ?? null));
}, cancelErr => {
callback(cancelErr);
});
}
}
catch (cancelErr) {
callback(cancelErr);
return;
}
callback(fromCancel ? null : (err ?? null));
return;
}
// Explicit cancel must not surface as a stream failure
if (fromCancel) {
callback(null);
return;
}
// No cancel hook: propagate destroy(err) so piped destinations observe the failure (#3011)
callback(err ?? null);
},
});
}
}
cancel(reason) {
if (this.readable.destroyed) {
const errored = this.readable.errored;
if (errored != null) {
return fakeRejectPromise(errored);
}
return fakePromise();
}
pendingCancelReasons.set(this.readable, { value: reason });
const readable = this.readable;
readable.once('error', noop);
// Always destroy without an error: cancel intent is in the WeakMap for aware streams.
// For wrapped streams, pipeThrough forwards cancel(reason) to the source explicitly.
const whenDone = finished(readable).then(() => undefined, err => {
// Swallow intentional cancel artifacts: destroy(reason) on wrapped streams, and
// ERR_STREAM_PREMATURE_CLOSE from destroy() before the readable is drained.
// Still propagate cancel-hook failures and racing destroy(err) failures.
if (isExpectedCancelError(err, reason)) {
return undefined;
}
throw err;
});
readable.destroy();
return whenDone.finally(() => {
readable.off('error', noop);
pendingCancelReasons.delete(readable);
});
}
locked = false;
getReader(_options) {
const iterator = this.readable[Symbol.asyncIterator]();
this.locked = true;
const thisReadable = this.readable;
return {
read() {
return iterator.next();
},
releaseLock: () => {
if (iterator.return) {
const retResult$ = iterator.return();
if (retResult$.then) {
retResult$.then(() => {
this.locked = false;
});
return;
}
}
this.locked = false;
},
cancel: (reason) => {
this.locked = false;
// Route through stream cancel so pipeThrough's cancel forwarding still runs.
return this.cancel(reason);
},
get closed() {
return Promise.race([
once(thisReadable, 'end'),
once(thisReadable, 'error').then(err => Promise.reject(err)),
]);
},
};
}
[Symbol.asyncIterator](options) {
const preventCancel = options?.preventCancel === true;
// Node's default async iterator destroys on return; honor preventCancel via destroyOnReturn.
const iterator = (typeof this.readable.iterator === 'function'
? this.readable.iterator({ destroyOnReturn: !preventCancel })
: this.readable[Symbol.asyncIterator]());
const iterable = {
[Symbol.asyncIterator]() {
return this;
},
[Symbol.asyncDispose]: async () => {
await iterator.return?.();
if (!preventCancel && !this.readable.destroyed) {
this.readable.destroy();
}
},
next: () => iterator.next(),
return: () => {
if (!preventCancel && !this.readable.destroyed) {
this.readable.destroy();
}
return iterator.return?.() || fakePromise({ done: true, value: undefined });
},
throw: (err) => {
if (!this.readable.destroyed) {
this.readable.destroy(err);
}
return iterator.throw?.(err) || fakePromise({ done: true, value: undefined });
},
};
return iterable;
}
values(options) {
return this[Symbol.asyncIterator](options);
}
tee() {
throw new Error('Not implemented');
}
async pipeToWriter(writer) {
try {
for await (const chunk of this) {
await writer.write(chunk);
}
await writer.close();
}
catch (err) {
try {
await writer.abort(err);
}
catch {
// Ignore abort failures; still surface the original write/close error.
}
throw err;
}
}
pipeTo(destination) {
if (isPonyfillWritableStream(destination)) {
// Prefer `.pipe()` over `stream/promises.pipeline`: the promise pipeline allocates an
// AbortController and aborts it on teardown (DOMException + stack), which dominates
// per-request cost for short pipeThrough TransformStreams (e.g. body-size limiting).
// Still mirror pipeline's premature-close behavior: dest/src `close` without a normal
// `finish` / end must reject so canceling a TransformStream readable cannot hang pipeTo.
const src = this.readable;
const dest = destination.writable;
return new Promise((resolve, reject) => {
let settled = false;
let completed = false;
const settle = (err) => {
if (settled) {
return;
}
settled = true;
src.off('error', onError);
dest.off('error', onError);
dest.off('finish', onFinish);
src.off('close', onSrcClose);
dest.off('close', onDestClose);
if (err != null) {
reject(err);
}
else {
resolve();
}
};
const prematureCloseError = () => {
const err = new Error('Premature close');
err.code = 'ERR_STREAM_PREMATURE_CLOSE';
return err;
};
const onError = (err) => {
if (!src.destroyed) {
src.once('error', noop);
src.destroy(err);
}
if (!dest.destroyed) {
dest.once('error', noop);
dest.destroy(err);
}
settle(err);
};
const onFinish = () => {
completed = true;
settle();
};
const onSrcClose = () => {
if (!completed && !src.readableEnded) {
onError(prematureCloseError());
}
};
const onDestClose = () => {
if (!completed && !dest.writableFinished) {
onError(prematureCloseError());
}
};
src.once('error', onError);
dest.once('error', onError);
dest.once('finish', onFinish);
src.once('close', onSrcClose);
dest.once('close', onDestClose);
src.pipe(dest, { end: true });
});
}
const writer = destination.getWriter();
return this.pipeToWriter(writer);
}
pipeThrough({ writable, readable, }) {
const pipePromise = this.pipeTo(writable);
pipePromise.catch(err => {
if (!this.readable.destroyed) {
this.readable.once('error', noop);
this.readable.destroy(err);
}
});
if (isPonyfillReadableStream(readable)) {
const onError = (err) => {
if (!this.readable.destroyed) {
this.readable.once('error', noop);
this.readable.destroy(err);
}
};
const onEnd = () => {
if (!this.readable.destroyed && !this.readable.readableEnded) {
this.readable.push(null);
}
};
readable.readable.once('error', onError);
readable.readable.once('finish', onEnd);
readable.readable.once('close', onEnd);
// finally() re-settles like the original promise; must catch or it becomes unhandled.
pipePromise
.finally(() => {
readable.readable.off('error', onError);
readable.readable.off('finish', onEnd);
readable.readable.off('close', onEnd);
})
.catch(() => { });
// Forward cancel(reason) to the source first (sequential) so a racing pipe
// destroy(err) cannot overwrite the cancel reason with ERR_STREAM_PREMATURE_CLOSE.
const outCancel = readable.cancel.bind(readable);
readable.cancel = async (reason) => {
await this.cancel(reason);
try {
await outCancel(reason);
}
catch (err) {
if (!isExpectedCancelError(err, reason)) {
throw err;
}
}
};
}
return readable;
}
static [Symbol.hasInstance](instance) {
return isReadableStream(instance);
}
static from(iterable) {
return new PonyfillReadableStream(Readable.from(iterable));
}
[Symbol.toStringTag] = 'ReadableStream';
}
function isPonyfillReadableStream(obj) {
return obj?.readable != null;
}
function isPonyfillWritableStream(obj) {
return obj?.writable != null;
}