← back to Costa Rica

lib/async-harden.js

64 lines

'use strict';
// Async-error safety for Express 4 (Cody gate, cycle 11, TK-10346).
//
// Express 4 does NOT forward a rejected promise from an async route handler to
// error-handling middleware. An uncaught throw inside `async (req,res)=>{...}`
// therefore becomes an unhandledRejection — which on Node 15+ CRASHES the process
// (now caught by server.js's net, so the server stays up) and leaves the request
// HANGING with no response ever sent.
//
// `harden(router)` wraps every route handler in a router so a thrown/rejected
// handler calls next(err) -> the app's global error handler returns a clean 500.
// A handler that catches its own errors never rejects, so the wrap is a transparent
// pass-through for it: existing try/catch blocks still win, and there is no
// double-response. Error-handling middleware (arity 4) is left untouched.

// KNOWN LIMITATION (Cody gate, cycle 11 — same as upstream express-async-handler):
// if a handler calls next() SYNCHRONOUSLY to advance the chain and THEN, in
// still-pending async work, throws/rejects, this wrapper will call next(err) a
// SECOND time after the chain has already moved on — Express's fallback then
// destroys the socket if headers are already sent. Every handler across the 5
// hardened routers (auth.js's authRequired/optionalAuth + routes/{app,admin,build,
// logo-agent,webhooks}.js) was checked at cycle-11 and NONE does this today
// (authRequired/optionalAuth are fully synchronous — they return undefined or the
// non-thenable `res`, so no spurious .catch fires). If a future async middleware
// calls next() and later awaits/throws, this file is the first place to look.
//
// Wrap one handler so BOTH a synchronous throw and a rejected promise reach next().
function asyncWrap(handle) {
  return function hardened(req, res, next) {
    let out;
    try {
      out = handle(req, res, next);
    } catch (e) {
      next(e); // synchronous throw
      return;
    }
    if (out && typeof out.then === 'function') {
      Promise.resolve(out).catch(next); // async rejection
    }
    return out;
  };
}

// Wrap every route handler in `router.stack`. Idempotent (won't double-wrap) so it
// is safe to call more than once on the same router. Returns the router for
// inline use: app.use('/x', harden(require('./routes/x'))).
function harden(router) {
  if (!router || !Array.isArray(router.stack)) return router;
  for (const layer of router.stack) {
    const routeStack = layer.route && layer.route.stack;
    if (!Array.isArray(routeStack)) continue; // a mounted sub-router / plain middleware layer — skip
    for (const h of routeStack) {
      // Only wrap ordinary handlers (arity < 4); never error-handling middleware.
      if (typeof h.handle === 'function' && h.handle.length < 4 && !h.handle.__hardened) {
        h.handle = asyncWrap(h.handle);
        h.handle.__hardened = true;
      }
    }
  }
  return router;
}

module.exports = { asyncWrap, harden };