openvibers/openvibe.shared Commits

Shared 1.27.0: openvibe-shared/express-async. Express 4 ignores what a handler returns, so a promise an async handler rejects (a database error, a thrown problem) became an unhandled rejection, and Node 22 ends the process on one; the move to PostgreSQL made most handlers async (nine services had 5-41 such routes, confirmed to exit). routeAsyncErrors(app) patches the app's own Express 4 Layer once so the rejection reaches next(err) and the service's error handler answers, as a synchronous throw always did; error handlers too; a no-op on Express 5. instrument() calls it, so every service is covered by a pin bump. npm test 40/40.

OP OpenVibers committed 28 Sept 2026 · cded2bc16e · 8 files, +99 −4


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 5764bc1..dbd802b 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,10 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 1.27.0 — 2026-09-28
++
++**`openvibe-shared/express-async`: an async handler's rejection no longer ends the process.** Express 4 ignores what a handler returns, so a promise that an `async (req, res) => …` handler rejects (a database error, a thrown problem) became an unhandled rejection, and Node 22 exits on one. The move to PostgreSQL (ADR-035) made most handlers async; nine services had between 5 and 41 such routes. `routeAsyncErrors(app)` patches the app's own Express 4 `Layer` once, so the rejection goes to `next(err)` and the service's error handler answers, exactly as a synchronous throw always did. Error handlers get the same treatment. On Express 5, which handles promises itself, it does nothing. **`instrument()` calls it**, and every service calls `instrument()`, so a pin bump is the whole change. `test/express-async.test.js`.
++
## 1.26.0 — 2026-09-28
**Async gauges** (ADR-035: services on PostgreSQL read their counts asynchronously). A gauge's `collect()` may return a promise. The `/metrics` handler (`metricsHandler`, and the `instrument()` middleware that uses it) now calls the new `registry.metricsAsync({ timeoutMs = 2000 })`. It awaits every async collect together. A collect that rejects or runs past its budget is left out of that scrape and is never reported stale, just as a throwing one already was. The synchronous `registry.metrics()` is unchanged, and it leaves an async gauge out. The handler is now an async function; Express and plain `http` callers need no change. `test/metrics.test.js` covers async, rejecting and slow collects. This replaces the "one scrape behind" workaround the first PostgreSQL services used.
ddiff --git a/README.md b/README.md
iindex 31b66f7..1c6abba 100644
---- a/README.md
++++ b/README.md
@@@ -59,6 +59,8 @@ const m = require('openvibe-shared/metrics').instrument(app, { service: 'communi
m.registry.gauge({ name: 'jobs', help: 'Jobs by state', labelNames: ['state'], collect: () => rows });
// collect may be async (a database read): /metrics awaits it, up to 2 s, and leaves out one that fails.
m.registry.gauge({ name: 'docs', help: 'Documents', collect: async () => (await db.one(sql`SELECT count(*)::int AS n FROM docs`)).n });
++// instrument() also routes an async handler's rejection to next(err) (openvibe-shared/express-async): Express 4
++// would leave it unhandled, and Node 22 ends the process on an unhandled rejection.
const ready = require('openvibe-shared/ready').createReadiness({
service: 'community', release: release.release,
ddiff --git a/STATUS.json b/STATUS.json
iindex d0dc678..a59ce38 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "1.26.0",
++ "version": "1.27.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/express-async.js b/express-async.js
nnew file mode 100644
iindex 0000000..95d3653
---- /dev/null
++++ b/express-async.js
@@@ -0,0 +1,51 @@
++'use strict';
++/**
++ * openvibe-shared/express-async — route an async Express 4 handler's rejection to next(err).
++ *
++ * Express 4 calls a handler and ignores what it returns, so a promise an `async (req, res) => …` handler rejects
++ * (a database error, a thrown problem) becomes an unhandled rejection, and Node 22 ends the process on one. Services
++ * moved to PostgreSQL (ADR-035) made most handlers async, so every OpenVibe service needs this. It patches the Layer
++ * class of the app's own express copy, once: a handler's rejection goes to next(err), where the service's error
++ * handler answers (a 500 problem), exactly as a synchronous throw always did. Error handlers (four arguments) get the
++ * same treatment. Express 5 handles promises itself: there it does nothing.
++ *
++ * require('openvibe-shared/express-async').routeAsyncErrors(app); // instrument() (openvibe-shared/metrics) calls it
++ */
++const PATCHED = Symbol.for('openvibe.shared.expressAsync');
++
++function layerClassOf(app) {
++ if (!app || typeof app.use !== 'function') return null;
++ if (!app._router && typeof app.lazyrouter === 'function') app.lazyrouter(); // Express 4 only
++ const router = app._router;
++ if (!router || !Array.isArray(router.stack) || !router.stack.length) return null;
++ const Layer = router.stack[0].constructor;
++ return Layer && Layer.prototype && typeof Layer.prototype.handle_request === 'function' ? Layer : null;
++}
++
++function settle(out, next, fallback) {
++ if (out && typeof out.then === 'function') out.then(undefined, (err) => next(err || fallback || new Error('async handler rejected')));
++}
++
++/** → true when it patched, false when there was nothing to patch (Express 5, or patched already). */
++function routeAsyncErrors(app) {
++ const Layer = layerClassOf(app);
++ if (!Layer || Layer.prototype[PATCHED]) return false;
++ Layer.prototype.handle_request = function handle(req, res, next) {
++ const fn = this.handle;
++ if (fn.length > 3) return next(); // an error handler is not a request handler (as Express does)
++ let out;
++ try { out = fn(req, res, next); } catch (err) { return next(err); }
++ return settle(out, next);
++ };
++ Layer.prototype.handle_error = function handleError(error, req, res, next) {
++ const fn = this.handle;
++ if (fn.length !== 4) return next(error);
++ let out;
++ try { out = fn(error, req, res, next); } catch (err) { return next(err); }
++ return settle(out, next, error);
++ };
++ Layer.prototype[PATCHED] = true;
++ return true;
++}
++
++module.exports = { routeAsyncErrors };
ddiff --git a/metrics.js b/metrics.js
iindex 8157220..b7c599f 100644
---- a/metrics.js
++++ b/metrics.js
@@@ -381,6 +381,8 @@ function instrument(app, { service, release, path = '/metrics', normalize = null
releaseInfo(registry, { service, release });
app.use(http.middleware);
app.get(path, metricsHandler(registry));
++ // Every service instruments its app: an async handler's rejection reaches next(err) instead of ending the process.
++ require('./express-async').routeAsyncErrors(app);
return { registry, http, stop: proc.stop };
}
ddiff --git a/package-lock.json b/package-lock.json
iindex db280bf..b5d1ba2 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "1.26.0",
++ "version": "1.27.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "1.26.0",
++ "version": "1.27.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 7244273..8c0e66c 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "1.26.0",
++ "version": "1.27.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
@@@ -29,6 +29,7 @@
"./icons": "./ov-icons.js",
"./island": "./island.js",
"./legal": "./legal.js",
++ "./express-async": "./express-async.js",
"./metrics": "./metrics.js",
"./middleware": "./middleware.js",
"./nav-icons": "./nav-icons.js",
ddiff --git a/test/express-async.test.js b/test/express-async.test.js
nnew file mode 100644
iindex 0000000..09b8bde
---- /dev/null
++++ b/test/express-async.test.js
@@@ -0,0 +1,35 @@
++'use strict';
++// openvibe-shared/express-async: an async handler's rejection reaches the error handler (a 500), never an unhandled
++// rejection that ends the process; synchronous throws, next(err) and async error handlers keep working; patching twice
++// is a no-op; instrument() applies it.
++const assert = require('assert');
++let express;
++try { express = require('express'); } catch { console.log('express-async: skipped (express not installed)'); process.exit(0); }
++const { routeAsyncErrors } = require('../express-async');
++const metrics = require('../metrics');
++
++(async () => {
++ let unhandled = 0;
++ process.on('unhandledRejection', () => { unhandled++; });
++ const app = express();
++ metrics.instrument(app, { service: 't', release: 'r' });
++ assert.strictEqual(routeAsyncErrors(app), false, 'instrument() patched it already');
++ app.get('/async', async () => { throw Object.assign(new Error('db down'), { status: 503 }); });
++ app.get('/sync', () => { throw new Error('sync'); });
++ app.get('/ok', async (_req, res) => { res.json({ ok: true }); });
++ app.get('/next', (_req, _res, next) => next(new Error('via next')));
++ app.get('/bad-handler', async () => { throw new Error('first'); });
++ app.use(async (err, req, res, next) => { if (req.path === '/bad-handler') throw new Error('handler broke'); res.status(err.status || 500).json({ error: err.message }); });
++ app.use((err, _req, res, _next) => { res.status(500).json({ error: `last: ${err.message}` }); });
++ const s = await new Promise((r) => { const x = app.listen(0, '127.0.0.1', () => r(x)); });
++ const get = async (p) => { const r = await fetch(`http://127.0.0.1:${s.address().port}${p}`); return [r.status, await r.json()]; };
++ try {
++ assert.deepStrictEqual(await get('/async'), [503, { error: 'db down' }]);
++ assert.deepStrictEqual(await get('/sync'), [500, { error: 'sync' }]);
++ assert.deepStrictEqual(await get('/ok'), [200, { ok: true }]);
++ assert.deepStrictEqual(await get('/next'), [500, { error: 'via next' }]);
++ assert.deepStrictEqual(await get('/bad-handler'), [500, { error: 'last: handler broke' }], 'an async error handler that throws reaches the next one');
++ assert.strictEqual(unhandled, 0);
++ console.log('express-async: all checks passed');
++ } finally { s.close(); }
++})().catch((err) => { console.error(err); process.exit(1); });