openvibers/openvibe.tips Commits

Move Tips errors and shutdown onto openvibe-sdk/service (#3)

OP OpenVibers committed 2 Oct 2026 · be90dfd75b · 6 files, +168 −56

checks run by GitHub CI (test, test-pg) · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.Tips/pull/3

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 95c316d..258afc0 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
},
@@@ -1133,9 +1133,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.25.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
-- "integrity": "sha512-8eurZLyFUtB8XN16hLUuYkah+qwS3tkvdzul5qdZkJTi0aZ4q8dq2QGWSq6pN5mtVPdJyuJ8Mo53r36lixmP1g==",
++ "version": "0.26.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
++ "integrity": "sha512-+RK89ZG4WAKIRh89yr3ieIvnyJVNSOI+dsbBqGNcr9UHsJStdxtM75zSDq8cUNpBPQ1X6ElatZrjU9S3s0aEkg==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex 6a8902b..45fb7d5 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
},
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 6e713a4..4276e18 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -39,8 +39,8 @@
* but the creator and granted services.
*/
const express = require('express');
--const { http } = require('openvibe-contracts');
--const { TipsError, fail, userSubject, displayName, inputError } = require('../util');
++const svc = require('openvibe-sdk/service');
++const { fail, userSubject, displayName, inputError } = require('../util');
const { idempotent } = require('./idempotency');
const CAP = {
@@@ -401,24 +401,17 @@ function v1Router({ domain, apiAuth, limits }) {
return r;
}
--/** Async-safe handler: TipsError → problem+json; anything else → 500. */
--function wrap(fn) {
-- return async (req, res, next) => {
-- try {
-- const p = await fn(req, res, next);
-- if (p && typeof p.catch === 'function') p.catch((e) => sendError(req, res, e));
-- } catch (e) { sendError(req, res, e); }
-- };
--}
++/**
++ * Async-safe handler: TipsError → problem+json; anything else → 500 tips.internal with 'internal error'.
++ * Built from openvibe-sdk/service (plan T1) with Tips' own options — the fallback code and detail, and
++ * `extra` nested as { details } below 500, are the kit's internalCode / internalDetail / extra: 'details';
++ * `map: inputError` keeps the database-refusal mapping (a value PostgreSQL cannot store → 422
++ * tips.invalid_input). The exported signatures stay (fn) / (req, res, e), so no call site moves.
++ */
++const TIPS_ERRORS = { name: 'Tips', extra: 'details', internalCode: 'tips.internal', internalDetail: 'internal error', map: inputError };
--function sendError(req, res, e) {
-- if (res.headersSent) return;
-- e = inputError(e) || e;
-- if (e instanceof TipsError) {
-- return http.sendProblem(res, e.status, e.code, { detail: e.detail || e.message, ctx: req.ov, extra: e.extra && e.status < 500 ? { details: e.extra } : undefined });
-- }
-- console.error('[Tips] unexpected error:', e);
-- return http.sendProblem(res, 500, 'tips.internal', { detail: 'internal error', ctx: req.ov });
--}
++const wrap = (fn) => svc.wrap(fn, TIPS_ERRORS);
++
++const sendError = (req, res, e) => svc.sendError(res, req, e, undefined, TIPS_ERRORS);
module.exports = { v1Router, wrap, sendError, CAP };
ddiff --git a/server/index.js b/server/index.js
iindex 7791385..a447dd5 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -21,8 +21,29 @@ const { openDb, migrate } = require('./db');
const { createApp } = require('./app');
const { createValkey } = require('openvibe-sdk/valkey');
const { createRegistry } = require('openvibe-shared/metrics');
++const { gracefulStop } = require('openvibe-sdk/service');
--async function main() {
++/**
++ * The process stop (openvibe-sdk/service, plan T1): the job timers and the relays stop taking new
++ * work (they ran before the old server.close), the HTTP drain runs (defaults 4000/5000), then the
++ * database and Valkey close; past the deadline the process exits 0, as the hand-rolled 5 s timer did.
++ * Exported so a test can inject `exit` and `signals: false`.
++ */
++function createLifecycle({ server, app, timers = [], exit, signals } = {}) {
++ const { db, valkey, domain, keys, outbox } = app.locals;
++ return gracefulStop({
++ name: 'Tips', server, deadlineExitCode: 0, exit, signals,
++ stop: [
++ () => timers.forEach(clearInterval),
++ () => keys.stop(),
++ () => domain.overlays.close(),
++ () => outbox.stop(),
++ ],
++ close: [() => db.close(), () => { if (valkey) return valkey.close(); }],
++ });
++}
++
++async function start() {
const config = loadConfig();
const registry = createRegistry();
const db = openDb(config, { registry });
@@@ -51,26 +72,15 @@ async function main() {
});
server.keepAliveTimeout = 65_000;
-- let closing = false;
-- async function shutdown(signal) {
-- if (closing) return;
-- closing = true;
-- console.log(`[Tips] ${signal} — closing`);
-- setTimeout(() => process.exit(0), 5000).unref();
-- timers.forEach(clearInterval);
-- keys.stop();
-- await domain.overlays.close().catch(() => {});
-- await outbox.stop();
-- await new Promise((resolve) => server.close(resolve));
-- await db.close().catch(() => {});
-- if (valkey) await valkey.close().catch(() => {});
-- process.exit(0);
-- }
-- process.on('SIGTERM', async () => { await shutdown('SIGTERM'); });
-- process.on('SIGINT', async () => { await shutdown('SIGINT'); });
++ createLifecycle({ server, app, timers });
++ return { server, app };
++}
++
++if (require.main === module) {
++ start().catch((e) => {
++ console.error(`[Tips] could not start: ${e.message}`);
++ process.exit(1);
++ });
}
--main().catch((e) => {
-- console.error(`[Tips] could not start: ${e.message}`);
-- process.exit(1);
--});
++module.exports = { start, createLifecycle };
ddiff --git a/server/util.js b/server/util.js
iindex c19155f..24f5ab2 100644
---- a/server/util.js
++++ b/server/util.js
@@@ -3,16 +3,12 @@
/** Small shared pieces: ids, time, the error type, input checks. */
const crypto = require('crypto');
const { ids, validate } = require('openvibe-contracts');
++const { createServiceError } = require('openvibe-sdk/service');
--class TipsError extends Error {
-- constructor(status, code, detail, extra) {
-- super(detail || code);
-- this.status = status;
-- this.code = code;
-- this.detail = detail;
-- this.extra = extra;
-- }
--}
++// openvibe-sdk/service (plan T1): the kit's error class with Tips' name. Same constructor
++// (status, code, detail, extra) and same fields, so `instanceof TipsError`, fail() and every
++// call site stay exactly as they were.
++const TipsError = createServiceError('TipsError');
function fail(status, code, detail, extra) { throw new TipsError(status, code, detail, extra); }
ddiff --git a/test/service-kit.test.js b/test/service-kit.test.js
nnew file mode 100644
iindex 0000000..4aaae05
---- /dev/null
++++ b/test/service-kit.test.js
@@@ -0,0 +1,113 @@
++'use strict';
++/**
++ * openvibe-sdk/service (plan T1): Tips' error half is the kit's wrap/sendError with Tips' options, so a
++ * TipsError answers its own status/code/detail with `extra` nested as { details } below 500, and anything
++ * else answers 500 tips.internal with 'internal error'. The kit's JSON parser (wired the way a service
++ * adopts it) answers 413 request.too_large / 400 request.invalid_json / 415 request.unsupported_encoding;
++ * Tips' own app keeps express.json, so its current codes are pinned unchanged (413 request.too_large,
++ * 400 request.malformed_json). The entry point's graceful stop runs its stop and close steps in order,
++ * then exits 0.
++ */
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const svc = require('openvibe-sdk/service');
++const { boot, check, done } = require('./helpers/app');
++const { wrap } = require('../server/api/v1');
++const { TipsError } = require('../server/util');
++
++(async () => {
++ const t = await boot();
++
++ // A service-shaped app on the kit's parser + Tips' wrap, the way the recipe wires it.
++ const app = express();
++ app.post('/echo', svc.jsonBody({ limit: '64kb' }), wrap(async (req, res) => { res.json({ ok: true }); }));
++ app.post('/refuse', wrap(async () => { throw new TipsError(422, 'tips.test_refusal', 'that is not allowed', { field: 'creator' }); }));
++ app.post('/gone', wrap(async () => { throw new TipsError(503, 'tips.billing_unavailable', 'Billing is not answering right now; nothing was charged'); }));
++ app.post('/boom', wrap(async () => { throw new Error('boom'); }));
++ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const post = (p, body, headers = {}) => fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body });
++ const read = async (r) => { const text = await r.text(); return { status: r.status, headers: r.headers, text, json: JSON.parse(text) }; };
++
++ await check('the kit parser: a JSON body over 64 kB is 413 request.too_large', async () => {
++ const r = await read(await post('/echo', JSON.stringify({ padding: 'y'.repeat(70 * 1024) })));
++ assert.strictEqual(r.status, 413, r.text);
++ assert.strictEqual(r.json.code, 'request.too_large');
++ });
++
++ await check('the kit parser: malformed JSON is 400 request.invalid_json', async () => {
++ const r = await read(await post('/echo', '{oops'));
++ assert.strictEqual(r.status, 400, r.text);
++ assert.strictEqual(r.json.code, 'request.invalid_json');
++ });
++
++ await check('the kit parser: an unreadable Content-Encoding is 415 request.unsupported_encoding', async () => {
++ const r = await read(await post('/echo', '{}', { 'content-encoding': 'xz' }));
++ assert.strictEqual(r.status, 415, r.text);
++ assert.strictEqual(r.json.code, 'request.unsupported_encoding');
++ });
++
++ await check('Tips\' app keeps its own parser answers: over 64 kB 413 request.too_large, malformed 400 request.malformed_json', async () => {
++ const big = await read(await fetch(`${t.base}/api/v1/checkout`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ padding: 'y'.repeat(70 * 1024) }) }));
++ assert.strictEqual(big.status, 413, big.text);
++ assert.strictEqual(big.json.code, 'request.too_large');
++ const bad = await read(await fetch(`${t.base}/api/v1/checkout`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' }));
++ assert.strictEqual(bad.status, 400, bad.text);
++ assert.strictEqual(bad.json.code, 'request.malformed_json');
++ });
++
++ await check('a TipsError answers its own status/code/detail and nests extra as { details } below 500', async () => {
++ const r = await read(await post('/refuse', '{}'));
++ assert.strictEqual(r.status, 422, r.text);
++ assert.strictEqual(r.json.code, 'tips.test_refusal');
++ assert.strictEqual(r.json.detail, 'that is not allowed');
++ assert.deepStrictEqual(r.json.details, { field: 'creator' });
++ assert.strictEqual(r.json.error, 'that is not allowed');
++ });
++
++ await check('a 503 TipsError keeps its detail (the fallback detail is only for an unexpected 500)', async () => {
++ const r = await read(await post('/gone', '{}'));
++ assert.strictEqual(r.status, 503, r.text);
++ assert.strictEqual(r.json.code, 'tips.billing_unavailable');
++ assert.strictEqual(r.json.detail, 'Billing is not answering right now; nothing was charged');
++ assert.strictEqual(r.json.details, undefined);
++ });
++
++ await check('an unexpected throw answers 500 tips.internal with \'internal error\'', async () => {
++ const r = await read(await post('/boom', '{}'));
++ assert.strictEqual(r.status, 500, r.text);
++ assert.strictEqual(r.json.code, 'tips.internal');
++ assert.strictEqual(r.json.detail, 'internal error');
++ });
++
++ await check('the entry point stop runs its stop and close steps in order, then exits 0', async () => {
++ const steps = [];
++ const spy = (obj, method, label) => { const orig = obj[method].bind(obj); obj[method] = (...a) => { steps.push(label); return orig(...a); }; };
++ spy(t.app.locals.keys, 'stop', 'keys.stop');
++ spy(t.app.locals.domain.overlays, 'close', 'overlays.close');
++ spy(t.app.locals.outbox, 'stop', 'outbox.stop');
++ spy(t.app.locals.db, 'close', 'db.close');
++ const closesValkey = !!(t.app.locals.valkey && typeof t.app.locals.valkey.close === 'function');
++ if (closesValkey) spy(t.app.locals.valkey, 'close', 'valkey.close');
++
++ const stopServer = http.createServer(t.app);
++ await new Promise((resolve) => stopServer.listen(0, '127.0.0.1', resolve));
++
++ const { createLifecycle } = require('../server/index');
++ const exits = [];
++ const lifecycle = createLifecycle({ server: stopServer, app: t.app, timers: [], exit: (code) => exits.push(code), signals: false });
++ const code = await lifecycle.stop('SIGTERM');
++
++ const expected = ['keys.stop', 'overlays.close', 'outbox.stop', 'db.close'];
++ if (closesValkey) expected.push('valkey.close');
++ assert.strictEqual(code, 0);
++ assert.deepStrictEqual(exits, [0]);
++ assert.deepStrictEqual(steps, expected);
++ assert.strictEqual(stopServer.listening, false);
++ });
++
++ server.close();
++ try { await t.close(); } catch { /* the lifecycle above already closed the db and valkey */ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });