openvibers/openvibe.news Commits
Move News errors and shutdown onto openvibe-sdk/service (#3)
Changes
@@@ -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"@@@ -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"},@@@ -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 };@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"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-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1181,9 +1181,9 @@
@@ -1181,9 +1181,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"
@@ -31,7 +31,7 @@
@@ -31,7 +31,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"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-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -2,64 +2,26 @@
@@ -2,64 +2,26 @@
/**
/**
* Errors as RFC 9457 problems (contracts errors.problem@1, which keeps the legacy { error } field),
* 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. */
/** 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. */
/** 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. */
/** 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 };
module.exports = { ApiError, asApiError, run, jsonBody, privateNoStore };
@@ -6,29 +6,40 @@
@@ -6,29 +6,40 @@
* client secret are set) and the worker (the OpenVibe.Sources cursor pull).
* client secret are set) and the worker (the OpenVibe.Sources cursor pull).
*/
*/
const { createApp } = require('./app');
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -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); });