npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

hal-response

v0.1.2

Published

hal responses

Readme

hal-response

Typed, consistent HTTP responses for Node.js / Express APIs. Every reply — success or failure — is serialized into one predictable JSON envelope, so your clients can rely on a single contract.

  • HalError + ready-made error classes → for failures
  • HalSuccess → for successes
  • AsyncHandler → wraps async controllers so thrown errors reach your handler
npm install hal-response

Requires Node.js >= 22. Peer: Express 5.

Table of contents


The response envelope

Both sides share the same skeleton, so the client can branch on success:

if (body.success) {
  // use body.data
} else {
  // show body.error.message
}

Success

{
  "success": true,
  "statusCode": 200,
  "service": "userService",
  "message": "User fetched",
  "data": { "id": 1, "name": "Nitin" },
  "meta": { "requestId": "req_1", "timestamp": "2026-07-18T10:00:01Z" }
}

service is optional. If you construct HalSuccess without one (new HalSuccess()), the field is omitted from the response entirely.

Error

{
  "success": false,
  "statusCode": 400,
  "error": {
    "code": "BAD_REQUEST",
    "message": "Invalid request payload",
    "details": [{ "field": "email", "issue": "Email format is invalid" }]
  },
  "meta": { "requestId": "req_1", "timestamp": "2026-07-18T10:00:01Z" }
}

Optional fields (message, _links, details, requestId, extra meta) appear only when you provide them. meta.timestamp is always generated.


Quick start

import express from "express";
import {
  HalSuccess,
  AsyncHandler,
  HalError,
  NotFoundError,
} from "hal-response";

const app = express();

// 1. configure the success builder once per controller/service
const response = new HalSuccess({ service: "userService" });

// 2. wrap async controllers so thrown errors reach the middleware
app.get(
  "/users/:id",
  AsyncHandler(async (req, res) => {
    const user = await db.findUser(req.params.id);
    if (!user) throw new NotFoundError("User not found");
    res.status(200).json(response.ok(user));
  })
);

// 3. one error middleware, registered LAST
app.use((err, req, res, next) => {
  if (err instanceof HalError) {
    res.status(err.statusCode).json(err.serialize());
    return;
  }
  next(err);
});

Errors

Throwing errors

Every error class takes the same constructor:

new BadRequestError(message, details?, meta?)

| Argument | Type | Purpose | | --------- | ------------------------- | ----------------------------------------- | | message | string | Human-readable message | | details | Record<string, any>[] | Optional field-level info (validation etc)| | meta | Record<string, any> | Optional extras merged into response meta|

import { UnprocessableEntityError } from "hal-response";

throw new UnprocessableEntityError(
  "Validation failed",
  [
    { field: "email", issue: "Email format is invalid" },
    { field: "age", issue: "Must be 18 or older" },
  ],
  { attemptId: "abc123" }
);

The error middleware

Register one handler, after all routes. It catches anything that is a HalError and serializes it; everything else is passed on to Express.

import { HalError } from "hal-response";
import type { Request, Response, NextFunction } from "express";

export function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
  if (err instanceof HalError) {
    res.status(err.statusCode).json(
      // optional: HAL links + a request id
      err.serialize({ self: { href: req.originalUrl } }, req.headers["x-request-id"] as string)
    );
    return;
  }
  next(err); // not one of ours — let Express handle it
}

// app.use(errorHandler)  // register LAST
const { HalError } = require("hal-response");

function errorHandler(err, req, res, next) {
  if (err instanceof HalError) {
    res.status(err.statusCode).json(
      err.serialize({ self: { href: req.originalUrl } }, req.headers["x-request-id"])
    );
    return;
  }
  next(err);
}

module.exports = { errorHandler };

serialize(links?, requestId?) — both arguments optional.

Built-in error classes

Import any of these and throw it. Each fixes its own statusCode + code.

| Class | Status | error.code | | --------------------------- | ------ | ----------------------- | | BadRequestError | 400 | BAD_REQUEST | | UnauthorizedError | 401 | UNAUTHORIZED | | ForbiddenError | 403 | FORBIDDEN | | NotFoundError | 404 | NOT_FOUND | | ConflictError | 409 | CONFLICT | | PayloadTooLargeError | 413 | PAYLOAD_TOO_LARGE | | UnprocessableEntityError | 422 | UNPROCESSABLE_ENTITY | | TooManyRequestsError | 429 | RATE_LIMITED | | InternalServerError | 500 | INTERNAL_SERVER_ERROR | | NotImplementedError | 501 | NOT_IMPLEMENTED | | BadGatewayError | 502 | BAD_GATEWAY | | ServiceUnavailableError | 503 | SERVICE_UNAVAILABLE | | GatewayTimeoutError | 504 | GATEWAY_TIMEOUT |

Custom error classes

Extend HalError and set statusCode + code:

import { HalError } from "hal-response";
import { StatusCodes } from "http-status-codes";

export class PaymentRequiredError extends HalError {
  statusCode = StatusCodes.PAYMENT_REQUIRED; // 402
  code = "PAYMENT_REQUIRED";
}

throw new PaymentRequiredError("Subscription expired");

Success responses

Configure once, reuse

Unlike errors, a success is built in the controller, not thrown. Configure HalSuccess once per controller with the service name, then reuse it:

import { HalSuccess } from "hal-response";

// top of userController.ts
const response = new HalSuccess({ service: "userService" });

export const getUser = AsyncHandler(async (req, res) => {
  const user = await db.findUser(req.params.id);
  res.status(200).json(response.ok(user));
});

export const createUser = AsyncHandler(async (req, res) => {
  const user = await db.createUser(req.body);
  res.status(201).json(response.created(user, { requestId: req.headers["x-request-id"] }));
});

The configured service is emitted at the top level of every response. It is optional — call new HalSuccess() with no argument (or {}) to omit it.

Success methods

| Method | Status | When to use | | ------------------------- | ------ | ---------------------------------------- | | ok(data, opts?) | 200 | GET, or a successful update (PUT/PATCH) | | created(data, opts?) | 201 | POST that created a new resource | | accepted(data, opts?) | 202 | Async / background job accepted | | noContent(opts?) | 204 | Success with no body (e.g. DELETE) | | partialContent(data, opts?) | 206 | Range requests (streaming, downloads) |

Anything else: serialize(data, statusCode, opts?).

opts is { message?, links?, requestId?, meta? } — all optional:

response.ok(users, {
  message: "Users fetched",
  links: { self: { href: "/users" }, next: { href: "/users?page=2" } },
  requestId: req.headers["x-request-id"],
  meta: { total: 128 },
});

AsyncHandler

Express does not automatically route a rejected promise from an async controller to your error middleware. AsyncHandler wraps the controller so any thrown error (or rejection) is forwarded via next(err) — which is what lets a throw new NotFoundError(...) reach the HalError handler.

import { AsyncHandler } from "hal-response";

router.get(
  "/users/:id",
  AsyncHandler(async (req, res) => {
    const user = await db.findUser(req.params.id);
    if (!user) throw new NotFoundError("User not found"); // caught -> next(err)
    res.json(response.ok(user));
  })
);

Rule: wrap every async controller with AsyncHandler.

Using transactions inside a controller

AsyncHandler forwards a thrown error to your middleware, but it does not know about your database transaction — so you must roll back before the error escapes. The pattern: try the work, catch to roll back and re-throw (so AsyncHandler still routes it to the HalError handler), and finally to release the session.

import mongoose from "mongoose";
import { AsyncHandler, HalSuccess, ConflictError } from "hal-response";

const response = new HalSuccess({ service: "walletService" });

export const transfer = AsyncHandler(async (req, res) => {
  const { from, to, amount } = req.body;
  const session = await mongoose.startSession();
  session.startTransaction();

  try {
    const sender = await Wallet.findById(from).session(session);
    if (sender.balance < amount) {
      // a HalError thrown here is caught below, rolled back, then re-thrown
      throw new ConflictError("Insufficient balance");
    }

    await Wallet.updateOne({ _id: from }, { $inc: { balance: -amount } }, { session });
    await Wallet.updateOne({ _id: to },   { $inc: { balance:  amount } }, { session });

    await session.commitTransaction();          // success -> persist
    res.status(200).json(response.ok({ from, to, amount }));
  } catch (err) {
    await session.abortTransaction();           // failure -> undo everything
    throw err;                                   // re-throw -> AsyncHandler -> middleware
  } finally {
    session.endSession();                        // always release the session
  }
});
import { AsyncHandler, HalSuccess, ConflictError } from "hal-response";

const response = new HalSuccess({ service: "walletService" });

export const transfer = AsyncHandler(async (req, res) => {
  const { from, to, amount } = req.body;

  // Prisma auto-rolls back if the callback throws — no manual abort needed.
  const result = await prisma.$transaction(async (tx) => {
    const sender = await tx.wallet.findUnique({ where: { id: from } });
    if (sender.balance < amount) {
      throw new ConflictError("Insufficient balance"); // rolls back the tx
    }

    await tx.wallet.update({ where: { id: from }, data: { balance: { decrement: amount } } });
    await tx.wallet.update({ where: { id: to },   data: { balance: { increment: amount } } });
    return { from, to, amount };
  });

  res.status(200).json(response.ok(result));
});

Key points:

  • Re-throw after rollback. Swallowing the error would leave the request hanging; re-throwing lets AsyncHandler route it to the HalError handler.
  • HalErrors work naturally — throw ConflictError, BadRequestError, etc. inside the transaction and the client still gets the correct status.
  • With Prisma/TypeORM interactive transactions the rollback is automatic when the callback throws; with Mongoose you abort manually in catch.

API reference

HalError (abstract)

| Member | Description | | ----------------------------- | -------------------------------------------------- | | statusCode | HTTP status (defined by the subclass) | | code | Machine-readable code (defined by the subclass) | | message | Human-readable message | | details? | Optional array of extra info | | meta? | Optional extras merged into response meta | | isOperational | Always true — marks an expected, handled error | | serialize(links?, requestId?) | Builds the error JSON body |

HalSuccess

Constructed with { service?: string } (the argument and service are both optional). Methods: ok, created, accepted, noContent, partialContent, and the general serialize(data, statusCode?, opts?).

AsyncHandler(fn)

Wraps an async Express controller and forwards errors to next.

Exported types

SerializedErrorResponse, SerializedSuccessResponse<T>, HalLinks, HalSuccessConfig, SerializeOptions, AsyncController.


License

MIT © nitin thakur