openvibers/openvibe.vip Commits
Move VIP errors and shutdown onto openvibe-sdk/service (#5)
Changes
@@@ -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"@@@ -32,7 +32,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"},@@@ -9,8 +9,8 @@* cancellation are handed to OpenVibe.Billing, and entitlement truth is Billing's.*/const express = require('express');--const { http } = require('openvibe-contracts');--const { VipError, fail, userSubject, isUserSubject, entityRef, bool } = require('../util');++const svc = require('openvibe-sdk/service');++const { fail, userSubject, isUserSubject, entityRef, bool } = require('../util');const CAP = {planCreate: 'vip.plan.create',@@@ -376,23 +376,16 @@ function v1Router({ domain, apiAuth, limits }) {return r;}--/** Async-safe handler: VipError → 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: VipError → problem+json; anything else → 500 vip.internal with 'internal error'.++ * Built from openvibe-sdk/service (plan T1) with VIP's own options — the fallback code and detail, and++ * `extra` nested as { details } below 500, are the kit's internalCode / internalDetail / extra: 'details'.++ * The exported signatures stay (req, res, e), so no call site moves.++ */++const VIP_ERRORS = { name: 'VIP', extra: 'details', internalCode: 'vip.internal', internalDetail: 'internal error' };--function sendError(req, res, e) {-- if (res.headersSent) return;-- if (e instanceof VipError) {-- 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('[VIP] unexpected error:', e);-- return http.sendProblem(res, 500, 'vip.internal', { detail: 'internal error', ctx: req.ov });--}++const wrap = (fn) => svc.wrap(fn, VIP_ERRORS);++++const sendError = (req, res, e) => svc.sendError(res, req, e, undefined, VIP_ERRORS);module.exports = { v1Router, wrap, sendError, CAP };@@@ -10,40 +10,59 @@*/const { loadConfig } = require('./config');const { createApp } = require('./app');++const { gracefulStop } = require('openvibe-sdk/service');--const config = loadConfig();--if (config.isProduction && !config.formSecret) {-- console.error('[VIP] VIP_FORM_SECRET must be set in production (it signs the pages\' anti-forgery tokens)');-- process.exit(1);++/**++ * The process stop (openvibe-sdk/service, plan T1): the job timers and relays stop taking new work++ * (they ran before the old server.close), the HTTP drain runs (defaults 4000/5000), then the domain++ * database closes; 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 { domain, keys, outbox, metrics } = app.locals;++ return gracefulStop({++ name: 'VIP', server, deadlineExitCode: 0, exit, signals,++ stop: [++ () => timers.forEach(clearInterval),++ () => outbox.stop(),++ () => keys.stop(),++ () => { if (metrics && metrics.stop) metrics.stop(); },++ ],++ close: [() => domain.db.close()],++ });}--(async () => {--const app = await createApp({ config });--const { domain, keys, outbox, metrics } = app.locals;--keys.start();----const timers = [];--if (config.jobs.enabled) {-- const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[VIP] job:', e.message)); }, ms); t.unref(); timers.push(t); };-- every(config.jobs.refreshIntervalMs, async () => await domain.entitlements.refreshDue());-- every(6 * 3600 * 1000, async () => await outbox.outbox.prune());-- outbox.start();++++async function start() {++ const config = loadConfig();++ if (config.isProduction && !config.formSecret) {++ console.error('[VIP] VIP_FORM_SECRET must be set in production (it signs the pages\' anti-forgery tokens)');++ process.exit(1);++ }++++ const app = await createApp({ config });++ const { domain, keys, outbox } = app.locals;++ keys.start();++++ const timers = [];++ if (config.jobs.enabled) {++ const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[VIP] job:', e.message)); }, ms); t.unref(); timers.push(t); };++ every(config.jobs.refreshIntervalMs, async () => await domain.entitlements.refreshDue());++ every(6 * 3600 * 1000, async () => await outbox.outbox.prune());++ outbox.start();++ }++++ const server = app.listen(config.port, config.host, () => {++ console.log(`[VIP] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${domain.db.store})`);++ console.log(`[VIP] billing ${config.billing.url}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (outbox accumulates)'}; billing events ${config.events.webhookSecrets.length ? 'accepted' : 'not accepted (VIP_EVENTS_SECRET unset)'}`);++ });++ server.keepAliveTimeout = 65_000;++++ createLifecycle({ server, app, timers });++ return { server, app };}--const server = app.listen(config.port, config.host, () => {-- console.log(`[VIP] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${domain.db.store})`);-- console.log(`[VIP] billing ${config.billing.url}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (outbox accumulates)'}; billing events ${config.events.webhookSecrets.length ? 'accepted' : 'not accepted (VIP_EVENTS_SECRET unset)'}`);--});--server.keepAliveTimeout = 65_000;----function shutdown(signal) {-- console.log(`[VIP] ${signal} — closing`);-- timers.forEach(clearInterval);-- outbox.stop();-- keys.stop();-- if (metrics && metrics.stop) metrics.stop();-- server.close(() => { Promise.resolve().then(() => domain.db.close()).catch(() => {}).finally(() => process.exit(0)); });-- setTimeout(() => process.exit(0), 5000).unref();++if (require.main === module) {++ start().catch((err) => { console.error('[VIP] failed to start:', err); process.exit(1); });}--process.on('SIGTERM', () => shutdown('SIGTERM'));--process.on('SIGINT', () => shutdown('SIGINT'));--})().catch((err) => { console.error('[VIP] failed to start:', err); process.exit(1); });++++module.exports = { start, createLifecycle };@@@ -2,16 +2,12 @@/** Small shared pieces: ids, time, the error type, input checks. */const { ids, validate } = require('openvibe-contracts');++const { createServiceError } = require('openvibe-sdk/service');--class VipError 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 VIP's name. Same constructor++// (status, code, detail, extra) and same fields, so `instanceof VipError`, fail() and every++// call site stay exactly as they were.++const VipError = createServiceError('VipError');function fail(status, code, detail, extra) { throw new VipError(status, code, detail, extra); }nnew file mode 100644@@@ -0,0 +1,115 @@++'use strict';++/**++ * openvibe-sdk/service (plan T1): VIP's error half is the kit's wrap/sendError with VIP's options, so a++ * VipError answers its own status/code/detail with `extra` nested as { details } below 500, and anything++ * else answers 500 vip.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;++ * VIP's own app keeps express.json, so its current codes are pinned unchanged. 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, harness } = require('./helpers/app');++const { wrap } = require('../server/api/v1');++const { VipError } = require('../server/util');++++const { test, run } = harness('service-kit');++++(async () => {++ const t = await boot();++++ // A service-shaped app on the kit's parser + VIP's 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 VipError(422, 'vip.test_refusal', 'that is not allowed', { field: 'provider' }); }));++ app.post('/gone', wrap(async () => { throw new VipError(503, 'vip.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) }; };++++ test('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');++ });++++ test('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');++ });++++ test('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');++ });++++ test('VIP\'s 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/plans`, { 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/plans`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' }));++ assert.strictEqual(bad.status, 400, bad.text);++ assert.strictEqual(bad.json.code, 'request.malformed_json');++ });++++ test('a VipError 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, 'vip.test_refusal');++ assert.strictEqual(r.json.detail, 'that is not allowed');++ assert.deepStrictEqual(r.json.details, { field: 'provider' });++ assert.strictEqual(r.json.error, 'that is not allowed');++ });++++ test('a 503 VipError 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, 'vip.billing_unavailable');++ assert.strictEqual(r.json.detail, 'Billing is not answering right now; nothing was charged');++ assert.strictEqual(r.json.details, undefined);++ });++++ test('an unexpected throw answers 500 vip.internal with \'internal error\'', async () => {++ const r = await read(await post('/boom', '{}'));++ assert.strictEqual(r.status, 500, r.text);++ assert.strictEqual(r.json.code, 'vip.internal');++ assert.strictEqual(r.json.detail, 'internal error');++ });++++ test('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.outbox, 'stop', 'outbox.stop');++ spy(t.app.locals.keys, 'stop', 'keys.stop');++ const stopsMetrics = !!(t.app.locals.metrics && typeof t.app.locals.metrics.stop === 'function');++ if (stopsMetrics) spy(t.app.locals.metrics, 'stop', 'metrics.stop');++ spy(t.app.locals.domain.db, 'close', 'domain.db.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 = ['outbox.stop', 'keys.stop'];++ if (stopsMetrics) expected.push('metrics.stop');++ expected.push('domain.db.close');++ assert.strictEqual(code, 0);++ assert.deepStrictEqual(exits, [0]);++ assert.deepStrictEqual(steps, expected);++ assert.strictEqual(stopServer.listening, false);++ });++++ await run().finally(async () => {++ server.close();++ try { await t.close(); } catch { /* the lifecycle above already closed the db */ }++ });++})();
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1133,9 +1133,9 @@
@@ -1133,9 +1133,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -32,7 +32,7 @@
@@ -32,7 +32,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -9,8 +9,8 @@
@@ -9,8 +9,8 @@
* cancellation are handed to OpenVibe.Billing, and entitlement truth is Billing's.
* cancellation are handed to OpenVibe.Billing, and entitlement truth is Billing's.
*/
*/
const express = require('express');
const express = require('express');
-const { http } = require('openvibe-contracts');
-const { VipError, fail, userSubject, isUserSubject, entityRef, bool } = require('../util');
+const svc = require('openvibe-sdk/service');
+const { fail, userSubject, isUserSubject, entityRef, bool } = require('../util');
const CAP = {
const CAP = {
planCreate: 'vip.plan.create',
planCreate: 'vip.plan.create',
@@ -376,23 +376,16 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -376,23 +376,16 @@ function v1Router({ domain, apiAuth, limits }) {
return r;
return r;
}
}
-/** Async-safe handler: VipError → 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: VipError → problem+json; anything else → 500 vip.internal with 'internal error'.
+ * Built from openvibe-sdk/service (plan T1) with VIP's own options — the fallback code and detail, and
+ * `extra` nested as { details } below 500, are the kit's internalCode / internalDetail / extra: 'details'.
+ * The exported signatures stay (req, res, e), so no call site moves.
+ */
+const VIP_ERRORS = { name: 'VIP', extra: 'details', internalCode: 'vip.internal', internalDetail: 'internal error' };
-function sendError(req, res, e) {
- if (res.headersSent) return;
- if (e instanceof VipError) {
- 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('[VIP] unexpected error:', e);
- return http.sendProblem(res, 500, 'vip.internal', { detail: 'internal error', ctx: req.ov });
-}
+const wrap = (fn) => svc.wrap(fn, VIP_ERRORS);
+
+const sendError = (req, res, e) => svc.sendError(res, req, e, undefined, VIP_ERRORS);
module.exports = { v1Router, wrap, sendError, CAP };
module.exports = { v1Router, wrap, sendError, CAP };
@@ -10,40 +10,59 @@
@@ -10,40 +10,59 @@
*/
*/
const { loadConfig } = require('./config');
const { loadConfig } = require('./config');
const { createApp } = require('./app');
const { createApp } = require('./app');
+const { gracefulStop } = require('openvibe-sdk/service');
-const config = loadConfig();
-if (config.isProduction && !config.formSecret) {
- console.error('[VIP] VIP_FORM_SECRET must be set in production (it signs the pages\' anti-forgery tokens)');
- process.exit(1);
+/**
+ * The process stop (openvibe-sdk/service, plan T1): the job timers and relays stop taking new work
+ * (they ran before the old server.close), the HTTP drain runs (defaults 4000/5000), then the domain
+ * database closes; 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 { domain, keys, outbox, metrics } = app.locals;
+ return gracefulStop({
+ name: 'VIP', server, deadlineExitCode: 0, exit, signals,
+ stop: [
+ () => timers.forEach(clearInterval),
+ () => outbox.stop(),
+ () => keys.stop(),
+ () => { if (metrics && metrics.stop) metrics.stop(); },
+ ],
+ close: [() => domain.db.close()],
+ });
}
}
-(async () => {
-const app = await createApp({ config });
-const { domain, keys, outbox, metrics } = app.locals;
-keys.start();
-
-const timers = [];
-if (config.jobs.enabled) {
- const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[VIP] job:', e.message)); }, ms); t.unref(); timers.push(t); };
- every(config.jobs.refreshIntervalMs, async () => await domain.entitlements.refreshDue());
- every(6 * 3600 * 1000, async () => await outbox.outbox.prune());
- outbox.start();
+
+async function start() {
+ const config = loadConfig();
+ if (config.isProduction && !config.formSecret) {
+ console.error('[VIP] VIP_FORM_SECRET must be set in production (it signs the pages\' anti-forgery tokens)');
+ process.exit(1);
+ }
+
+ const app = await createApp({ config });
+ const { domain, keys, outbox } = app.locals;
+ keys.start();
+
+ const timers = [];
+ if (config.jobs.enabled) {
+ const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[VIP] job:', e.message)); }, ms); t.unref(); timers.push(t); };
+ every(config.jobs.refreshIntervalMs, async () => await domain.entitlements.refreshDue());
+ every(6 * 3600 * 1000, async () => await outbox.outbox.prune());
+ outbox.start();
+ }
+
+ const server = app.listen(config.port, config.host, () => {
+ console.log(`[VIP] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${domain.db.store})`);
+ console.log(`[VIP] billing ${config.billing.url}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (outbox accumulates)'}; billing events ${config.events.webhookSecrets.length ? 'accepted' : 'not accepted (VIP_EVENTS_SECRET unset)'}`);
+ });
+ server.keepAliveTimeout = 65_000;
+
+ createLifecycle({ server, app, timers });
+ return { server, app };
}
}
-const server = app.listen(config.port, config.host, () => {
- console.log(`[VIP] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${domain.db.store})`);
- console.log(`[VIP] billing ${config.billing.url}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (outbox accumulates)'}; billing events ${config.events.webhookSecrets.length ? 'accepted' : 'not accepted (VIP_EVENTS_SECRET unset)'}`);
-});
-server.keepAliveTimeout = 65_000;
-
-function shutdown(signal) {
- console.log(`[VIP] ${signal} — closing`);
- timers.forEach(clearInterval);
- outbox.stop();
- keys.stop();
- if (metrics && metrics.stop) metrics.stop();
- server.close(() => { Promise.resolve().then(() => domain.db.close()).catch(() => {}).finally(() => process.exit(0)); });
- setTimeout(() => process.exit(0), 5000).unref();
+if (require.main === module) {
+ start().catch((err) => { console.error('[VIP] failed to start:', err); process.exit(1); });
}
}
-process.on('SIGTERM', () => shutdown('SIGTERM'));
-process.on('SIGINT', () => shutdown('SIGINT'));
-})().catch((err) => { console.error('[VIP] failed to start:', err); process.exit(1); });
+
+module.exports = { start, createLifecycle };
@@ -2,16 +2,12 @@
@@ -2,16 +2,12 @@
/** Small shared pieces: ids, time, the error type, input checks. */
/** Small shared pieces: ids, time, the error type, input checks. */
const { ids, validate } = require('openvibe-contracts');
const { ids, validate } = require('openvibe-contracts');
+const { createServiceError } = require('openvibe-sdk/service');
-class VipError 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 VIP's name. Same constructor
+// (status, code, detail, extra) and same fields, so `instanceof VipError`, fail() and every
+// call site stay exactly as they were.
+const VipError = createServiceError('VipError');
function fail(status, code, detail, extra) { throw new VipError(status, code, detail, extra); }
function fail(status, code, detail, extra) { throw new VipError(status, code, detail, extra); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+'use strict';
+/**
+ * openvibe-sdk/service (plan T1): VIP's error half is the kit's wrap/sendError with VIP's options, so a
+ * VipError answers its own status/code/detail with `extra` nested as { details } below 500, and anything
+ * else answers 500 vip.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;
+ * VIP's own app keeps express.json, so its current codes are pinned unchanged. 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, harness } = require('./helpers/app');
+const { wrap } = require('../server/api/v1');
+const { VipError } = require('../server/util');
+
+const { test, run } = harness('service-kit');
+
+(async () => {
+ const t = await boot();
+
+ // A service-shaped app on the kit's parser + VIP's 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 VipError(422, 'vip.test_refusal', 'that is not allowed', { field: 'provider' }); }));
+ app.post('/gone', wrap(async () => { throw new VipError(503, 'vip.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) }; };
+
+ test('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');
+ });
+
+ test('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');
+ });
+
+ test('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');
+ });
+
+ test('VIP\'s 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/plans`, { 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/plans`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' }));
+ assert.strictEqual(bad.status, 400, bad.text);
+ assert.strictEqual(bad.json.code, 'request.malformed_json');
+ });
+
+ test('a VipError 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, 'vip.test_refusal');
+ assert.strictEqual(r.json.detail, 'that is not allowed');
+ assert.deepStrictEqual(r.json.details, { field: 'provider' });
+ assert.strictEqual(r.json.error, 'that is not allowed');
+ });
+
+ test('a 503 VipError 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, 'vip.billing_unavailable');
+ assert.strictEqual(r.json.detail, 'Billing is not answering right now; nothing was charged');
+ assert.strictEqual(r.json.details, undefined);
+ });
+
+ test('an unexpected throw answers 500 vip.internal with \'internal error\'', async () => {
+ const r = await read(await post('/boom', '{}'));
+ assert.strictEqual(r.status, 500, r.text);
+ assert.strictEqual(r.json.code, 'vip.internal');
+ assert.strictEqual(r.json.detail, 'internal error');
+ });
+
+ test('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.outbox, 'stop', 'outbox.stop');
+ spy(t.app.locals.keys, 'stop', 'keys.stop');
+ const stopsMetrics = !!(t.app.locals.metrics && typeof t.app.locals.metrics.stop === 'function');
+ if (stopsMetrics) spy(t.app.locals.metrics, 'stop', 'metrics.stop');
+ spy(t.app.locals.domain.db, 'close', 'domain.db.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 = ['outbox.stop', 'keys.stop'];
+ if (stopsMetrics) expected.push('metrics.stop');
+ expected.push('domain.db.close');
+ assert.strictEqual(code, 0);
+ assert.deepStrictEqual(exits, [0]);
+ assert.deepStrictEqual(steps, expected);
+ assert.strictEqual(stopServer.listening, false);
+ });
+
+ await run().finally(async () => {
+ server.close();
+ try { await t.close(); } catch { /* the lifecycle above already closed the db */ }
+ });
+})();