openvibers/openvibe.news Commits

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

OP OpenVibers committed 2 Oct 2026 · 6d30b5e063 · 5 files, +95 −76

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

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

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 48f2f46..9ed2a15 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.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"
},
@@@ -1181,9 +1181,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 2f49fa2..9b6b0f4 100644
---- a/package.json
++++ b/package.json
@@@ -31,7 +31,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.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/http/errors.js b/server/http/errors.js
iindex 5f687f0..d28b143 100644
---- a/server/http/errors.js
++++ b/server/http/errors.js
@@@ -2,64 +2,26 @@
/**
* Errors as RFC 9457 problems (contracts errors.problem@1, which keeps the legacy { error } field),
-- * and the small request helpers every router shares.
++ * and the small request helpers every router shares. Built from openvibe-sdk/service (docs/service.md,
++ * the Blog row) so every service answers the same way; the exports stay put so no call site moves.
*/
--const express = require('express');
--const contracts = require('openvibe-contracts');
--const cache = require('openvibe-shared/cache-policy');
++const svc = require('openvibe-sdk/service');
--/** A refusal with a stable problem code (e.g. 404 'post.not_found'). */
--class ApiError extends Error {
-- constructor(status, code, detail, extra) {
-- super(detail || code);
-- this.name = 'ApiError';
-- this.status = status;
-- this.code = code;
-- this.extra = extra || null;
-- }
--}
++/** A refusal with a stable problem code (e.g. 404 'story.not_found'). */
++const ApiError = svc.createServiceError('ApiError');
++
++const o = { name: 'News API', publishing: true, ServiceError: ApiError };
/** Publishing package errors carry { status, code } too. */
--function asApiError(err) {
-- if (err instanceof ApiError) return err;
-- if (err && err.name === 'PublishingError' && Number.isInteger(err.status)) {
-- const extra = err.code === 'revision.conflict' ? { expected: err.expected, current: err.current } : null;
-- return new ApiError(err.status, err.code, err.message, extra);
-- }
-- if (err instanceof TypeError && err.message && !/Cannot read|is not a function|undefined/.test(err.message)) {
-- return new ApiError(422, 'request.invalid', err.message);
-- }
-- return null;
--}
++const asApiError = (err) => svc.asServiceError(err, o);
/** Wrap a JSON handler: its return value is the body; errors become problems. */
--function run(fn, status = 200) {
-- return async (req, res) => {
-- try {
-- const out = await fn(req, res);
-- if (out === undefined || res.headersSent) return;
-- res.status(typeof status === 'function' ? status(out) : status).json(out);
-- } catch (err) {
-- if (res.headersSent) return;
-- const e = asApiError(err);
-- if (e) return contracts.http.sendProblem(res, e.status, e.code, { detail: e.message, ctx: req.ov, extra: e.extra || undefined });
-- console.error('[News API]', err && err.stack ? err.stack : err);
-- contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
-- }
-- };
--}
++const run = (fn, status) => svc.run(fn, status, o);
--const jsonParser = express.json({ limit: '512kb' });
--/** JSON body parser whose syntax errors are problems too. */
--function jsonBody(req, res, next) {
-- jsonParser(req, res, (err) => (err ? contracts.http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov }) : next()));
--}
++/** JSON body parser whose failures are problems too: malformed 400, over the limit 413. */
++const jsonBody = svc.jsonBody();
/** Private, per-viewer responses: never stored by a shared cache, never indexed. */
--function privateNoStore(res) {
-- res.set('Cache-Control', cache.htmlHeaders({ private: true }));
-- res.vary('Cookie');
-- res.vary('Authorization');
--}
++const privateNoStore = svc.privateNoStore;
module.exports = { ApiError, asApiError, run, jsonBody, privateNoStore };
ddiff --git a/server/index.js b/server/index.js
iindex aba0dfa..a2d8082 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -6,29 +6,40 @@
* client secret are set) and the worker (the OpenVibe.Sources cursor pull).
*/
const { createApp } = require('./app');
++const { gracefulStop } = require('openvibe-sdk/service');
--(async () => {
--const { app, ctx } = await createApp();
--const { config } = ctx;
++/**
++ * The process stop (openvibe-sdk/service, docs/service.md's 5 s family): the Sources pull worker stops
++ * taking new work, the HTTP drain runs (defaults 4000/5000), then the outbox settles and the store
++ * 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, ctx, exit, signals }) {
++ return gracefulStop({
++ name: 'News', server, deadlineExitCode: 0, exit, signals,
++ stop: [() => ctx.worker.stop()],
++ close: [() => ctx.outbox.stop(), () => ctx.store.close()],
++ });
++}
--const server = app.listen(config.port, config.host, () => {
-- console.log(`[News] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);
-- console.log(`[News] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; webhook ${config.events.webhookSecrets.length ? 'on' : 'off (NEWS_EVENTS_SECRET unset)'}; Sources pull ${config.worker.enabled && config.sources.pullIntervalMs ? `every ${config.sources.pullIntervalMs} ms` : 'off'}; AI ${ctx.ai.enabled ? 'on' : 'off'}`);
--});
--server.keepAliveTimeout = 65_000;
--ctx.outbox.start();
--ctx.worker.start();
++async function start() {
++ const { app, ctx } = await createApp();
++ const { config } = ctx;
--function shutdown(signal) {
-- console.log(`[News] ${signal}: closing`);
-- ctx.worker.stop();
-- server.close(async () => {
-- try { await ctx.outbox.stop(); } catch { /* best effort */ }
-- try { await ctx.store.close(); } catch { /* already closed */ }
-- process.exit(0);
++ const server = app.listen(config.port, config.host, () => {
++ console.log(`[News] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);
++ console.log(`[News] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; webhook ${config.events.webhookSecrets.length ? 'on' : 'off (NEWS_EVENTS_SECRET unset)'}; Sources pull ${config.worker.enabled && config.sources.pullIntervalMs ? `every ${config.sources.pullIntervalMs} ms` : 'off'}; AI ${ctx.ai.enabled ? 'on' : 'off'}`);
});
-- setTimeout(() => process.exit(0), 5000).unref();
++ server.keepAliveTimeout = 65_000;
++ ctx.outbox.start();
++ ctx.worker.start();
++
++ createLifecycle({ server, ctx });
++ return { server, ctx };
++}
++
++if (require.main === module) {
++ start().catch((err) => { console.error('[News] failed to start:', err); process.exit(1); });
}
--process.on('SIGTERM', () => shutdown('SIGTERM'));
--process.on('SIGINT', () => shutdown('SIGINT'));
--})().catch((err) => { console.error('[News] failed to start:', err); process.exit(1); });
++
++module.exports = { start, createLifecycle };
ddiff --git a/test/service-kit.test.js b/test/service-kit.test.js
nnew file mode 100644
iindex 0000000..58456a9
---- /dev/null
++++ b/test/service-kit.test.js
@@@ -0,0 +1,46 @@
++'use strict';
++/**
++ * openvibe-sdk/service (plan T1): the shared JSON body parser answers 413 for a body over the limit
++ * (the hand-rolled one answered 400 request.invalid_json there), and the entry point's graceful stop
++ * runs its stop and close steps and exits 0.
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++const { createLifecycle } = require('../server/index');
++
++(async () => {
++ const t = await boot();
++
++ await check('a JSON body over 512 kB is 413 request.too_large, not 400 request.invalid_json', async () => {
++ const r = await t.api('/stories', { json: { headline: 'Big', padding: 'y'.repeat(600 * 1024) } });
++ assert.strictEqual(r.status, 413, r.text);
++ assert.strictEqual(r.json().code, 'request.too_large');
++ });
++
++ await check('the entry point stop runs its stop and close steps, 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.ctx.worker, 'stop', 'worker.stop');
++ spy(t.ctx.outbox, 'stop', 'outbox.stop');
++ spy(t.ctx.store, 'close', 'store.close');
++
++ const server = http.createServer(t.app);
++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
++
++ const exits = [];
++ const lifecycle = createLifecycle({ server, ctx: t.ctx, exit: (code) => exits.push(code), signals: false });
++ const code = await lifecycle.stop('SIGTERM');
++
++ assert.strictEqual(code, 0);
++ assert.deepStrictEqual(exits, [0]);
++ assert.deepStrictEqual(steps, ['worker.stop', 'outbox.stop', 'store.close']);
++ assert.strictEqual(server.listening, false);
++ });
++
++ await t.close();
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });