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.
Changes
@@@ -4,6 +4,10 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking 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.@@@ -59,6 +59,8 @@ const m = require('openvibe-shared/metrics').instrument(app, { service: 'communim.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,@@@ -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)",nnew file mode 100644@@@ -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 };@@@ -381,6 +381,8 @@ function instrument(app, { service, release, path = '/metrics', normalize = nullreleaseInfo(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 };}@@@ -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"@@@ -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",nnew file mode 100644@@@ -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); });
@@ -4,6 +4,10 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
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
## 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.
**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.
@@ -59,6 +59,8 @@ const m = require('openvibe-shared/metrics').instrument(app, { service: 'communi
@@ -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 });
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.
// 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 });
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({
const ready = require('openvibe-shared/ready').createReadiness({
service: 'community', release: release.release,
service: 'community', release: release.release,
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "1.26.0",
+ "version": "1.27.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"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)",
"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)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -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 };
@@ -381,6 +381,8 @@ function instrument(app, { service, release, path = '/metrics', normalize = null
@@ -381,6 +381,8 @@ function instrument(app, { service, release, path = '/metrics', normalize = null
releaseInfo(registry, { service, release });
releaseInfo(registry, { service, release });
app.use(http.middleware);
app.use(http.middleware);
app.get(path, metricsHandler(registry));
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 };
return { registry, http, stop: proc.stop };
}
}
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.26.0",
+ "version": "1.27.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.26.0",
+ "version": "1.27.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -29,6 +29,7 @@
@@ -29,6 +29,7 @@
"./icons": "./ov-icons.js",
"./icons": "./ov-icons.js",
"./island": "./island.js",
"./island": "./island.js",
"./legal": "./legal.js",
"./legal": "./legal.js",
+ "./express-async": "./express-async.js",
"./metrics": "./metrics.js",
"./metrics": "./metrics.js",
"./middleware": "./middleware.js",
"./middleware": "./middleware.js",
"./nav-icons": "./nav-icons.js",
"./nav-icons": "./nav-icons.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -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); });