-
-
Notifications
You must be signed in to change notification settings - Fork 6.5k
Description
Affected URL
https://nodejs.org/en/learn/getting-started/fetch
Describe the issue in detail:
Sorry, but examples of this type looks as code smell, like someone vomited on the page
import { Writable } from 'node:stream';
import { stream } from 'undici';
async function fetchGitHubRepos() {
const url = 'https://api.github.com/users/nodejs/repos';
await stream(
url,
{
method: 'GET',
headers: {
'User-Agent': 'undici-stream-example',
Accept: 'application/json',
},
},
res => {
let buffer = '';
return new Writable({
write(chunk, encoding, callback) {
buffer += chunk.toString();
callback();
},
final(callback) {
try {
const json = JSON.parse(buffer);
console.log(
'Repository Names:',
json.map(repo => repo.name)
);
} catch (error) {
console.error('Error parsing JSON:', error);
}
console.log('Stream processing completed.');
console.log(`Response status: ${res.statusCode}`);
callback();
},
});
}
);
}
fetchGitHubRepos().catch(console.error);I know some people really like this style with all that nested inline functions and params.
But you writing example, learning pages!
it should be understandable from first sight and don't force to count brackets and commas.
And much worse, this is example from honorable node.js - later that unreadable codesmell in someones production.
yes, I made it with chatgpt, but why not use readable, understandable code, which sometime better for debugging/maintainability. Yes, with jdoc, to show developers how code can looks like (yes, here too much jdoc).
And actually chatgpt highlighted problem with buffering.
import { Writable } from 'node:stream';
/**
* Writable stream that consumes an HTTP response body,
* buffers it safely, and parses it as JSON once the stream ends.
*
* IMPORTANT:
* - This class intentionally buffers the entire response in memory.
* - It is suitable for "normal-sized" JSON APIs, NOT unbounded streams.
*
* Fixes compared to typical doc examples:
* - Avoids string concatenation (uses Buffer chunks)
* - Enforces a maximum response size
* - Properly propagates stream errors
* - Keeps response metadata (status, headers) attached
*
* Intended usage:
*
* await stream(url, opts, res => new JsonCollector(res, { onJson }))
*/
export class JsonCollector extends Writable {
/**
* @param {import('undici').Dispatcher.ResponseData} res
* The HTTP response metadata (statusCode, headers, etc.).
* Provided by undici before the body stream starts.
*
* @param {Object} [opts]
* @param {number} [opts.maxBytes=10485760]
* Maximum number of bytes allowed to be buffered.
* Exceeding this limit will abort the stream with an error.
*
* @param {boolean} [opts.expectJsonContentType=false]
* If true, validates that the response Content-Type contains
* "application/json" before parsing.
*
* @param {(json: any, res: any) => void} [opts.onJson]
* Callback invoked after successful JSON parsing.
* Receives parsed JSON and the original response metadata.
*
* @param {(res: any) => void} [opts.onDone]
* Optional callback invoked after successful completion.
*/
constructor(res, opts = {}) {
super({
/**
* Ensures that incoming chunks are Buffers.
* This avoids implicit string decoding inside Node streams.
*/
decodeStrings: true,
});
/** @private */
this.res = res;
/** @private */
this.maxBytes = opts.maxBytes ?? 10 * 1024 * 1024; // 10 MiB default
/** @private */
this.expectJsonContentType = opts.expectJsonContentType ?? false;
/** @private */
this.onJson = opts.onJson ?? (() => {});
/** @private */
this.onDone = opts.onDone ?? (() => {});
/** @private */
this.chunks = [];
/** @private */
this.bytes = 0;
}
/**
* Called by the stream machinery for each incoming data chunk.
*
* Responsibilities:
* - Track total size
* - Enforce maxBytes limit
* - Store raw Buffers (no string concatenation)
*
* @param {Buffer} chunk
* @param {BufferEncoding} _enc
* @param {(err?: Error) => void} cb
* @private
*/
_write(chunk, _enc, cb) {
try {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
this.bytes += buf.length;
if (this.bytes > this.maxBytes) {
const err = new Error(
`Response too large: ${this.bytes} bytes (limit ${this.maxBytes})`
);
this.destroy(err);
return cb(err);
}
this.chunks.push(buf);
cb();
} catch (err) {
this.destroy(err);
cb(err);
}
}
/**
* Called exactly once when the upstream stream ends.
*
* Responsibilities:
* - Validate Content-Type (optional)
* - Concatenate buffered data
* - Decode UTF-8
* - Parse JSON
* - Invoke user callbacks
*
* @param {(err?: Error) => void} cb
* @private
*/
_final(cb) {
try {
if (this.expectJsonContentType) {
const ct = this._getHeader('content-type') || '';
if (!ct.toLowerCase().includes('application/json')) {
throw new Error(`Unexpected content-type: ${ct || '(missing)'}`);
}
}
const text = Buffer.concat(this.chunks).toString('utf8');
const json = JSON.parse(text);
this.onJson(json, this.res);
this.onDone(this.res);
cb();
} catch (err) {
this.destroy(err);
cb(err);
}
}
/**
* Called when the stream is destroyed or errors.
* Used to release buffered memory eagerly.
*
* @param {Error|null} err
* @param {(err?: Error|null) => void} cb
* @private
*/
_destroy(err, cb) {
this.chunks = [];
this.bytes = 0;
cb(err);
}
/**
* Retrieve a response header in a case-insensitive way.
* Supports both plain-object headers and Headers-like interfaces.
*
* @param {string} name
* @returns {string|undefined}
* @private
*/
_getHeader(name) {
const h = this.res?.headers;
if (!h) return undefined;
if (typeof h.get === 'function') return h.get(name);
const key = Object.keys(h).find(
(k) => k.toLowerCase() === name.toLowerCase()
);
return key ? h[key] : undefined;
}
}and usage
import { stream } from 'undici';
import { JsonCollector } from './JsonCollector.js';
const url = 'https://api.github.com/users/nodejs/repos';
const req = {
method: 'GET',
headers: {
'User-Agent': 'undici-stream-example',
Accept: 'application/json',
},
};
await stream(url, req, (res) => new JsonCollector(res, {
maxBytes: 2 * 1024 * 1024, // 2 MiB cap
expectJsonContentType: true, // optional
onJson: (json, r) => {
console.log('Repository Names:', json.map((repo) => repo.name));
console.log('status:', r.statusCode);
},
}));it produced as two separate files, but it can be combined in one and import removed, to make it a little simpler.
Metadata
Metadata
Assignees
Labels
Type
Projects
Status