Skip to content

Code examples #8433

@rybakovanton-metta

Description

@rybakovanton-metta

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

No one assigned

    Labels

    contentIssues/pr concerning contentlearnIssues/pr concerning the learn section

    Type

    No type

    Projects

    Status

    📋 Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions