openvibers/openvibe.sdk Commits

SDK 0.26.0 (plan T1 kits): openvibe-sdk/service is the service kit. gracefulStop() and within() are OpenVibe.Network's and OpenVibe.Community's server/graceful.js (stop steps, server.close with Connection: close on requests in flight, idle connections swept and event streams destroyed, close steps, one stop per process) plus the exit code of a blown deadline, beforeDrain and handles; the deadline timer stays referenced so a stuck step exits with the deadline's code. The error layer: createServiceError, ServiceError, asServiceError, run, wrap, sendError, jsonBody (400 request.invalid_json, 413 request.too_large), privateNoStore and the opt-in jsonErrors. The readiness, metrics, release and problem helpers are re-exported from openvibe-shared and openvibe-contracts, required on first use (the SDK gains no dependency). scripts/esm.js turns a lazy CommonJS getter into an ESM function. Types, ESM entry, docs/service.md, README and CHANGELOG. Tests: service (9 cases, a drain timeout with no requests cuts nothing), pack and browser entries.

OP OpenVibers committed 1 Oct 2026 · 314bb57dac · 17 files, +1394 −12


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex faaf57d..a5eb60d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -8,6 +8,24 @@ release may change an API and says so here.
++## 0.26.0 (2026-10-01)
++
++- `openvibe-sdk/service` (new, server; plan T1): the service kit. `gracefulStop({ name, server, stop, close, drainMs = 4000,
++ deadlineMs = 5000, deadlineExitCode = 1, signals = true, exit, log, beforeDrain, handles })` → `{ stop(signal?),
++ stopping() }` and `within(ms, promise)`: OpenVibe.Network's and OpenVibe.Community's `server/graceful.js` (stop steps,
++ then `server.close` with `Connection: close` on requests in flight, idle connections closed every 50 ms and event streams
++ destroyed, then close steps, exit 0; one stop per process), plus the exit code of a blown deadline (the 5 s family and
++ Media exit 0), `beforeDrain` and `handles` (closed last; a failure exits 1). Its deadline timer is not `unref()`'d, so a
++ step stuck on a promise ends in the deadline's exit code rather than a quiet exit 0. The error layer: `createServiceError(name)`,
++ `ServiceError`, `asServiceError(err, { publishing, map })`, `run(fn, status)`, `wrap(fn)`, `sendError(res, req, err, log)`,
++ `jsonBody({ limit = '512kb' })` (malformed: 400 `request.invalid_json`; too large: 413 `request.too_large`),
++ `privateNoStore(res)` and the opt-in `jsonErrors()`, with Reviews/Wiki's defaults and `extra` spread (or `extra: 'details'`
++ for Tips/VIP). `createReadiness`, `skip`, `safeReason`, `createRegistry`, `instrument`, `metricsHandler`, `isLoopbackDirect`,
++ `releaseInfo`, `createRelease` (openvibe-shared) and `problem`, `sendProblem` (openvibe-contracts) are re-exported, required on
++ first use from the service's own packages: the SDK gains no dependency. Recipe per family: `docs/service.md`.
++- `scripts/esm.js`: a getter on a CommonJS entry (a lazy re-export) becomes an ESM function that reads it on call, so
++ importing `openvibe-sdk/service` loads neither package.
++
## 0.25.2 (2026-09-29)
- `openvibe-sdk/realtime` follows Events' opaque cursors (ADR-042): it resumes from the last SSE id exactly as Events
ddiff --git a/README.md b/README.md
iindex fbff524..9675cce 100644
---- a/README.md
++++ b/README.md
@@@ -181,6 +181,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/geo` | both | `createGeoClient(client, {samples?, timeoutMs?})`: `nodes({role, region})` from Network's public node registry, `measure(nodes)` (beacon round trips, fastest first), `nearest({role, region, preferRegion})` → `{node, rtt_ms, measured}` (up/degraded nodes only; falls back to preferRegion when nothing can be measured) |
| `openvibe-sdk/openre` | server | `createOpenReClient(client, {baseUrl?, publicUrl?})`: OpenRe.Stream, the streaming engine. `streams` (`byExternalRef`, `create`, `get`, `update`, `rotateKey`, `destinations`, `addDestination`), `destinations` (`test`, `start`, `stop`, `logs`), `sessions` (`get`, `playback` cached 10 s, `end`, `outputs`), `workers`, `manageUrl`; `{subject}` acts for a person (X-OV-Subject) |
| `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` |
++| `openvibe-sdk/service` | server | the service kit: `gracefulStop({name, server, stop, close, drainMs, deadlineMs, deadlineExitCode, beforeDrain, handles})` → `{stop, stopping}`, `within(ms, p)`; `createServiceError(name)`, `ServiceError`, `asServiceError`, `run`, `wrap`, `sendError`, `jsonBody({limit})`, `privateNoStore`, `jsonErrors()`; re-exports (loaded on first use) of openvibe-shared `createReadiness`, `skip`, `safeReason`, `createRegistry`, `instrument`, `metricsHandler`, `isLoopbackDirect`, `releaseInfo`, `createRelease` and openvibe-contracts `problem`, `sendProblem` |
| `openvibe-sdk/testing` | Node | `createMockPlatform()`: fake Network (incl. developer apps and projects), Events, Media, Tools jobs and the Tools platform API on an in-process `fetch` |
| `openvibe-sdk/browser/openvibe-sdk.mjs` | browser | one self-contained ES module: core + auth (browser), registry, modules, realtime, media, community, jobs, tools, projects |
@@@ -344,6 +345,39 @@ The mock answers at the real public origins with real RS256 tokens and checks au
Helpers: `signUserToken()`, `signServiceToken()`, `signAppToken()`, `authorize()`, `setAuthorization()`, `publishEvent(envelope, publisher, { projectId, env })` (a registered app's `app:<id>` publisher implies them), `pruneEvents()`, `deliverEvents()`, `dropRealtime()`, `dropJobStreams()`, `addTool()`, `addMediaObject()`, and `stats` and `state` for assertions. Its `fetch` rejects on an aborted signal, as `fetch` does. It is a fake. It has no persistence, its visibility rules are simplified, and it has no Chat (no WebSocket mock).
++### Service kit (server)
++
++`openvibe-sdk/service` is the layer every service wrote around the shared pieces: a graceful stop and the error
++wrappers around the problem body. It does not reimplement readiness, metrics, `/release.json` or the problem body:
++those are re-exported from `openvibe-shared` (`/ready`, `/metrics`, `/release`) and `openvibe-contracts` (`http`),
++loaded from the service's own `node_modules` on first use (the SDK depends on neither; without the package the name
++throws an error that says which to install).
++
++```js
++const svc = require('openvibe-sdk/service');
++const ApiError = svc.createServiceError('ApiError'); // new ApiError(404, 'post.not_found', detail?, extra?)
++const o = { name: 'Blog API' }; // log prefix; extra: 'details' for Tips/VIP
++
++router.get('/api/posts/:id', svc.run(async (req) => posts.get(req.params.id), 200, o));
++router.post('/api/posts', svc.jsonBody({ limit: '512kb' }), svc.run(create, 201, o)); // bad JSON: 400 request.invalid_json
++app.use(svc.jsonErrors(o)); // opt-in, last: 404 + error mapping
++
++const server = app.listen(port);
++const lifecycle = svc.gracefulStop({
++ name: 'Blog', server,
++ stop: [() => worker.stop()], // nothing new starts
++ close: [() => outbox.stop(), () => db.close()], // after the HTTP drain
++ deadlineExitCode: 0, // the 5 s family exits 0 on a blown deadline
++});
++```
++
++Errors follow Reviews/Wiki: a 5xx other than 503 is logged; the code is the error's, else `internal.error` at 500 and
++`request.invalid` otherwise; a 500's detail is `'Internal error'`, never the message. On a signal `gracefulStop` runs
++the stop steps, `beforeDrain`, then closes the server (`Connection: close` on requests in flight, idle keep-alive
++connections closed every 50 ms, event streams destroyed), the close steps and `handles`, and exits 0; past `deadlineMs`
++it exits `deadlineExitCode`. `stopping()` turns true at once, for a readiness check. The recipe per service family is
++[docs/service.md](docs/service.md).
++
### Data: PostgreSQL and Valkey (server)
Every service stores its data in PostgreSQL 18 through PgBouncer, and shares caches, limits, queues and fan-out through Valkey (ADR-035). The data role on the host provides both; `roles/data/add-service.sh` in OpenVibe.Host sets `DATABASE_URL`, `DATABASE_DIRECT_URL`, `VALKEY_URL` and `VALKEY_PREFIX`.
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex e46d362..a2aa107 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.25.2, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.26.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -41,7 +41,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.25.2';
++const SDK_VERSION = '0.26.0';
module.exports = {
createClient,
ddiff --git a/docs/service.md b/docs/service.md
nnew file mode 100644
iindex 0000000..f18b0ef
---- /dev/null
++++ b/docs/service.md
@@@ -0,0 +1,200 @@
++# Adopting `openvibe-sdk/service`
++
++`openvibe-sdk/service` replaces the code each service wrote around the shared pieces: the graceful stop (three shapes in
++about 17 files, `server/graceful.js` copied into OpenVibe.Network and OpenVibe.Community) and the error wrappers around
++the problem body (`ApiError`/`TipsError`/`VipError`…, `run`, `wrap`, `sendError`, `jsonBody`, `privateNoStore`, the
++terminal error middleware). The plan T1 audit (`sdk-plan-t1-the-service-kit-audit`) is the inventory this recipe follows.
++
++Readiness, metrics, `/release.json` and the problem body are already shared in every service (`openvibe-shared/ready`,
++`/metrics`, `/release`; `openvibe-contracts` `http.problem`/`sendProblem`). The kit re-exports them under the same names
++and never copies them: each is required from the service's own `node_modules` on first use, so the SDK gains no
++dependency, and a service that lacks the package gets an error naming it (`code: 'sdk.missing_dependency'`). Switching
++`require('openvibe-shared/ready')` to `require('openvibe-sdk/service')` is optional and changes nothing.
++
++Convert one service at a time, smallest first (the audit's order): Blog, Codes, News, Deals, Coupons, VIP, Tips, Trade
++(errors only), Wiki, Reviews, Network, Community, Billing, Bot, Chat, Host, AI, Events, Search, Sources, Media,
++OpenRe.Stream, Live.
++
++## The stop
++
++```js
++const { gracefulStop, within } = require('openvibe-sdk/service');
++const lifecycle = gracefulStop({
++ name: 'Blog', // log prefix
++ server, // the http.Server (leave it out for a worker)
++ stop: [], // first, in order: nothing new starts (timers, pollers, relays)
++ beforeDrain: undefined, // (signal) => …, after the stop steps, while the server still takes connections
++ close: [], // after the HTTP drain, in order (outbox, analytics, database)
++ handles: undefined, // closed last (close(), else stop()/end()/quit(), or a function); a failure exits 1
++ drainMs: 4000, // requests in flight may take this long, then they are cut
++ deadlineMs: 5000, // the whole stop
++ deadlineExitCode: 1, // past the deadline
++ signals: true, // SIGTERM and SIGINT (false in tests)
++ exit: process.exit, // injected in tests
++ log: console,
++});
++// lifecycle.stop(signal?) → Promise<exit code>; lifecycle.stopping() → true from the first signal on
++```
++
++What it does, in order: `stopping()` turns true; the stop steps; `beforeDrain`; `server.close()`, `Connection: close`
++on every response still in flight, idle keep-alive connections closed at once and every 50 ms after, open
++`text/event-stream` responses destroyed (EventSource reconnects to another process); after `drainMs` whatever is open
++is cut; the close steps; the handles; `exit(0)` (1 when a handle failed). A step that throws is logged and the stop goes
++on. `exit` is called exactly once: by the end of the stop, or by the deadline with `deadlineExitCode`. A second signal
++returns the first stop's promise.
++
++Two things differ from the copies in Network and Community: the deadline timer is not `unref()`'d (a step stuck on a
++promise with nothing else alive used to let the process exit 0 on its own; now the deadline decides), and `stop()`
++resolves with the exit code it passed to `exit`.
++
++`within(ms, promise)` is the best-effort step: it waits for the promise but no longer than `ms`, and swallows a
++rejection. Use it where a step may hang or fail and the stop must go on regardless: `stop: [() => within(3000,
++mirror.flush())]`.
++
++**Readiness during the stop.** `stopping()` turns true before any step runs. A check makes `/api/ready` answer 503 for
++the whole stop:
++
++```js
++let lifecycle = null;
++const readiness = createReadiness({ service: 'live', release, checks: [
++ { name: 'accepting', required: true, check: () => !(lifecycle && lifecycle.stopping()) || 'stopping' },
++ // …the service's own checks
++] });
++// …after listen:
++lifecycle = gracefulStop({ name: 'Live', server, /* … */ });
++```
++
++### The 5 s family: Blog, Codes, Coupons, Deals, News, Trade, VIP, Host, Billing, Chat, Bot, Tips
++
++Today: `server.close(cb)` → close the store → `process.exit(0)`, and a 5 s timer that also exits **0**. Keep the exit
++code with `deadlineExitCode: 0`:
++
++```js
++gracefulStop({
++ name: 'Blog', server, deadlineExitCode: 0, // drainMs 4000, deadlineMs 5000 (the defaults)
++ stop: [() => worker.stop(), () => changelog.stop()],
++ close: [() => ctx.outbox.stop(), () => ctx.store.close()],
++});
++```
++
++- VIP closes `domain.db`; Billing adds `() => valkey.close()` before `db.close()`; Bot moves its sequence into `stop`
++ (clear timers, `keys.stop()`, `hub.close()`, `outbox.stop()`) and `close` (`db.close()`, Valkey); Tips likewise.
++- Chat's pre-drain flush: `stop: [() => within(3000, mirror.flush())]`, `close: [() => db.close()]`.
++- Host raises `server.requestTimeout` to 10 minutes for uploads; a 4 s drain cuts an upload in progress, as today's 5 s
++ timer does. Keep that, or give Host a `drainMs`/`deadlineMs` that matches the upload policy and the manifest's
++ `lifecycle.shutdown`.
++- Trade: the stop converts as above. Its `/release.json` and `/api/ready` are XState machine states and stay as they are.
++- New behaviour for this family (all of it safer): requests in flight are told `Connection: close`, idle keep-alive
++ connections no longer hold the stop open until the timer, event streams are ended.
++
++### Network and Community
++
++Delete `server/graceful.js` and import from the kit; the options are the same names.
++
++```js
++const { gracefulStop, within } = require('openvibe-sdk/service');
++gracefulStop({ name: 'Network', server, stop, close, drainMs: 8000, deadlineMs: 10000 }); // exit 1 past the deadline
++gracefulStop({ name: 'Community', server, stop, close }); // 4000 / 5000, exit 1
++```
++
++Their tests that inject `exit` and pass `signals: false` keep working; `stop()` now resolves with the exit code instead
++of always 0.
++
++### The handles family: AI, Events, Search (10 s), Sources (20 s)
++
++Today: `server.closeAllConnections?.()`, `server.close`, `handles.close().then(exit 0, exit 1)`, a hard timer that exits
++**1**. The `handles` option is that last step:
++
++```js
++gracefulStop({ name: 'AI', server, handles, drainMs: 8000, deadlineMs: 10000 }); // handles.close(): a rejection exits 1
++gracefulStop({ name: 'Sources', server, handles, drainMs: 15000, deadlineMs: 20000 });
++```
++
++Behaviour change: these services cut every connection first today; with the kit, requests in flight get `drainMs` to
++finish. Pick `drainMs` below the deadline.
++
++**Reviews and Wiki** (10 s, exit **0**, `h.stop().finally(exit 0)`): a failed `h.stop()` still exits 0 there, so make
++it a close step, not a handle:
++
++```js
++gracefulStop({ name: 'Reviews', server, close: [() => h.stop()], drainMs: 8000, deadlineMs: 10000, deadlineExitCode: 0 });
++```
++
++### Media: 70 s for the ffmpeg trailers
++
++Today: nine best-effort stops, `server.close`, then up to 70 s for ffmpeg to write the recordings' trailers; the timer
++exits **0**.
++
++```js
++gracefulStop({
++ name: 'Media', server, deadlineMs: 70000, deadlineExitCode: 0,
++ stop: [/* the health, verify and owner-subject jobs, the worker, the JWKS refresh, the events reset */],
++ beforeDrain: () => recorder.stopAll(), // ffmpeg starts writing trailers now
++ close: [() => recorder.settled(), () => vod.stop(), () => valkey.close()], // the recorder's wait: no `within` (the deadline bounds it)
++});
++```
++
++(`recorder.stopAll()`/`recorder.settled()` stand for Media's own calls.)
++
++### Live: last, it needs `beforeDrain` and the readiness flip
++
++Live flips `_bootComplete = false` before anything else so `/api/ready` answers 503 while it still serves, stops the
++chat/RTMP/FFmpeg children and restreamers, ends its SSE streams deliberately (`closeAll()`, then 300 ms), closes every
++SFU and relay, then `server.close`, with a 5 s timer that exits **1**.
++
++```js
++lifecycle = gracefulStop({
++ name: 'Live', server, // deadlineMs 5000, exit 1 (the defaults)
++ stop: [() => within(1500, children.stopAll()), () => restreamers.stopAll()],
++ beforeDrain: async () => { sse.closeAll(); await new Promise((r) => setTimeout(r, 300)); },
++ close: [() => sfus.closeAll(), () => relays.closeAll()],
++});
++```
++
++with the `accepting` readiness check above replacing `_bootComplete = false`. The drill branch (3 s) stays outside the
++kit.
++
++### OpenRe.Stream
++
++It has no hard timer today. `gracefulStop({ name: 'OpenRe', server, close: [...] })` adds one (5 s, exit 1): new
++behaviour, but a safer one.
++
++## The errors
++
++```js
++const svc = require('openvibe-sdk/service');
++const ApiError = svc.createServiceError('ApiError'); // (status, code, detail?, extra?) → .status .code .detail .extra
++```
++
++Every helper takes the same options: `{ name = 'service', log = console, extra = 'spread', internalCode =
++'internal.error', internalDetail = 'Internal error', publishing = false, map, noStore = false }`. The defaults are
++Reviews/Wiki `sendError`:
++
++| | |
++|---|---|
++| status | the error's (400–599), else 500 |
++| logged | 5xx except 503 |
++| code | the error's, else `internal.error` at 500 and `request.invalid` otherwise (an unexpected error's own code, such as a PostgreSQL `23505`, never reaches the body) |
++| detail | `'Internal error'` at 500, else the error's detail or message |
++| extra | spread into the body; `extra: 'details'` nests it as `{ details }`, below 500 only (Tips/VIP) |
++
++An error is a service's own refusal when it comes from `createServiceError`, when `map(err)` returns one, when
++`publishing` maps it (an `openvibe-publishing` `PublishingError`, with `revision.conflict`'s `expected`/`current`, and a
++`TypeError` that is not a bug, as 422 `request.invalid`), or when it has an HTTP status and a string code (any other
++service's error class).
++
++| family | today | with the kit |
++|---|---|---|
++| Blog, Trade, Deals, News (`server/http/errors.js`) | `ApiError`, `asApiError`, `run`, `jsonBody`, `privateNoStore` | `const o = { name: 'Blog API', publishing: true, ServiceError: ApiError }`; `run: (fn, s) => svc.run(fn, s, o)`, `asApiError: (e) => svc.asServiceError(e, o)`, `jsonBody: svc.jsonBody()`, `privateNoStore: svc.privateNoStore`; keep the module's exports so no call site moves |
++| Reviews, Wiki (`server/http/common.js`) | `sendError(res, req, err, log)`, `run(fn, status, log)` (Cache-Control private, no-store) | `svc.sendError(res, req, err, log, { name: 'Reviews' })`, `svc.run(fn, status, { name: 'Reviews', log, noStore: true })` |
++| Tips, VIP (`server/util.js`, `api/v1.js`) | `TipsError`, `fail`, `wrap`, `sendError` with `{ details }`, `tips.internal`, `'internal error'` | `const o = { name: 'Tips', extra: 'details', internalCode: 'tips.internal', internalDetail: 'internal error', map: inputError }`; `wrap: (fn) => svc.wrap(fn, o)` |
++| Bot (`server/app.js` terminal middleware) | 404 + error mapping for `/api/` and `/internal/`, text/plain for pages | `app.use(svc.jsonErrors({ name: 'Bot', internalCode: 'bot.internal', map: inputError }))`; Bot answers `request.malformed_json` today, the kit `request.invalid_json` (a code change: keep Bot's own middleware until its clients are checked) |
++
++`jsonBody({ limit = '512kb', parser })`: a JSON body parser without Express. Malformed JSON is 400
++`request.invalid_json` (`'Malformed JSON body'`); a body over the limit is 413 `request.too_large` (Blog's answered 400
++`request.invalid_json` there); an encoding other than gzip/deflate/br is 415. Like `express.json()` it parses
++`application/json` and `+json` only, objects and arrays only, an empty body is `{}`, and a body already parsed is left
++alone. `parser: express.json({ limit })` keeps Express's parser and maps its errors the same way.
++
++`jsonErrors()` is opt-in: adopting it decides which paths answer JSON and which text, so mount it only where that is
++already the behaviour.
ddiff --git a/esm/service.mjs b/esm/service.mjs
nnew file mode 100644
iindex 0000000..c1d8be8
---- /dev/null
++++ b/esm/service.mjs
@@@ -0,0 +1,20 @@
++// Generated by scripts/esm.js from src/service/index.js. Do not edit.
++import cjs from '../src/service/index.js';
++
++export const {
++ gracefulStop, within, DRAIN_MS, DEADLINE_MS, ServiceError, createServiceError,
++ asServiceError, sendError, run, wrap, jsonBody, privateNoStore,
++ jsonErrors,
++} = cjs;
++export function createReadiness(...args) { return cjs.createReadiness(...args); }
++export function skip(...args) { return cjs.skip(...args); }
++export function safeReason(...args) { return cjs.safeReason(...args); }
++export function createRegistry(...args) { return cjs.createRegistry(...args); }
++export function instrument(...args) { return cjs.instrument(...args); }
++export function metricsHandler(...args) { return cjs.metricsHandler(...args); }
++export function isLoopbackDirect(...args) { return cjs.isLoopbackDirect(...args); }
++export function releaseInfo(...args) { return cjs.releaseInfo(...args); }
++export function createRelease(...args) { return cjs.createRelease(...args); }
++export function problem(...args) { return cjs.problem(...args); }
++export function sendProblem(...args) { return cjs.sendProblem(...args); }
++export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex 1d4aac8..fd9e59d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.25.2",
++ "version": "0.26.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.25.2",
++ "version": "0.26.0",
"license": "MIT",
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
ddiff --git a/package.json b/package.json
iindex e71a783..41795b6 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.25.2",
++ "version": "0.26.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
@@@ -171,6 +171,12 @@
"import": "./esm/vip.mjs",
"default": "./src/vip.js"
},
++ "./service": {
++ "types": "./types/service.d.ts",
++ "browser": null,
++ "import": "./esm/service.mjs",
++ "default": "./src/service/index.js"
++ },
"./testing": {
"types": "./types/testing.d.ts",
"browser": null,
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 826fcd2..d736c97 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -42,16 +42,26 @@ const ENTRIES = {
'openre.mjs': '../src/openre.js',
'vip.mjs': '../src/vip.js',
'frame.mjs': '../src/frame.js',
++ 'service.mjs': '../src/service/index.js',
'testing.mjs': '../src/testing/index.js',
};
++/**
++ * A getter on the CommonJS exports is a lazy re-export (openvibe-sdk/service's openvibe-shared and openvibe-contracts
++ * functions): destructuring would load its package on import, so the ESM name is a function that reads it on call.
++ */
function render(file, target) {
-- const names = Object.keys(require(path.join(ROOT, 'esm', target)));
++ const mod = require(path.join(ROOT, 'esm', target));
++ const all = Object.keys(mod);
++ const names = all.filter((n) => !Object.getOwnPropertyDescriptor(mod, n).get);
++ const lazy = all.filter((n) => Object.getOwnPropertyDescriptor(mod, n).get);
const lines = [];
for (let i = 0; i < names.length; i += 6) lines.push(` ${names.slice(i, i + 6).join(', ')},`);
return `// Generated by scripts/esm.js from ${target.slice(3)}. Do not edit.\n`
+ `import cjs from '${target}';\n\n`
-- + `export const {\n${lines.join('\n')}\n} = cjs;\nexport default cjs;\n`;
++ + `export const {\n${lines.join('\n')}\n} = cjs;\n`
++ + lazy.map((n) => `export function ${n}(...args) { return cjs.${n}(...args); }\n`).join('')
++ + `export default cjs;\n`;
}
function main() {
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 1d98a74..7214565 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.25.2';
++const SDK_VERSION = '0.26.0';
module.exports = {
createClient,
ddiff --git a/src/service/errors.js b/src/service/errors.js
nnew file mode 100644
iindex 0000000..e6443a9
---- /dev/null
++++ b/src/service/errors.js
@@@ -0,0 +1,252 @@
++'use strict';
++/**
++ * The layer services wrote around the shared problem body (openvibe-contracts http.sendProblem, which this module
++ * calls and never copies): the error class, asServiceError, run/wrap/sendError, jsonBody, privateNoStore and the
++ * opt-in jsonErrors() terminal middleware. Defaults are Reviews/Wiki sendError's (server/http/common.js):
++ *
++ * status err.status when it is an HTTP error status (400–599), else 500
++ * logged status >= 500, except 503 (a dependency said "not now": expected, not a bug)
++ * code err.code, else 'internal.error' at 500 and 'request.invalid' otherwise
++ * detail 'Internal error' at 500 (never the raw message), else err.message
++ * extra spread into the body (Blog/Trade, Reviews/Wiki); `extra: 'details'` nests it as { details } below 500
++ * (Tips/VIP)
++ *
++ * Every helper takes the same options bag (run's third argument may also be a logger, as in Reviews):
++ * { name = 'service', log = console, extra = 'spread', internalCode = 'internal.error',
++ * internalDetail = 'Internal error', publishing = false, map, noStore = false }
++ * `map(err)` turns a service's own refusals into a ServiceError (Tips' inputError); `publishing` maps the
++ * openvibe-publishing errors the way Blog/Trade asApiError does.
++ */
++const shared = require('./shared');
++
++/** openvibe-contracts http.sendProblem, loaded on first use. */
++const sendProblem = (res, status, code, opts) => shared.sendProblem(res, status, code, opts);
++
++const BRAND = Symbol.for('openvibe.serviceError');
++
++/** A refusal with a stable problem code: new (createServiceError('ApiError'))(404, 'post.not_found', detail?, extra?). */
++function createServiceError(name = 'ServiceError') {
++ const ServiceErrorClass = class extends Error {
++ constructor(status, code, detail, extra) {
++ super(detail || code);
++ this.name = name;
++ this.status = status;
++ this.code = code;
++ this.detail = detail;
++ this.extra = extra || null;
++ }
++ };
++ Object.defineProperty(ServiceErrorClass, 'name', { value: name });
++ Object.defineProperty(ServiceErrorClass.prototype, BRAND, { value: true });
++ return ServiceErrorClass;
++}
++
++const ServiceError = createServiceError('ServiceError');
++
++function options(o) {
++ if (o && typeof o.error === 'function') return { log: o };
++ return o || {};
++}
++
++const httpStatus = (s) => Number.isInteger(s) && s >= 400 && s <= 599;
++
++/**
++ * The error as a refusal to answer with its own status and code, or null (an unexpected error: a 500).
++ * Any error with an HTTP status and a string code is one (every service's own class, a ServiceError).
++ */
++function asServiceError(err, opts) {
++ const o = options(opts);
++ if (!err || typeof err !== 'object') return null;
++ if (err[BRAND]) return err;
++ if (typeof o.map === 'function') {
++ const mapped = o.map(err);
++ if (mapped) return mapped;
++ }
++ if (o.publishing) {
++ const Make = o.ServiceError || ServiceError;
++ if (err.name === 'PublishingError' && Number.isInteger(err.status)) {
++ const extra = err.code === 'revision.conflict' ? { expected: err.expected, current: err.current } : null;
++ return new Make(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 Make(422, 'request.invalid', err.message);
++ }
++ }
++ if (httpStatus(err.status) && typeof err.code === 'string' && err.code) return err;
++ return null;
++}
++
++/** Any error → problem+json on res (nothing when the headers are already out). Returns the body sent, or null. */
++function sendError(res, req, err, log, opts) {
++ const o = { ...options(log), ...options(opts) };
++ const logger = o.log || console;
++ const known = asServiceError(err, o);
++ const e = known || err;
++ const status = known && httpStatus(known.status) ? known.status : (e && httpStatus(e.status) ? e.status : 500);
++ if (status >= 500 && status !== 503) logger.error(`[${o.name || 'service'}]`, err && err.stack ? err.stack : err);
++ if (res.headersSent) return null;
++ const internal = status === 500;
++ const code = (e && typeof e.code === 'string' && e.code && (known || !internal) ? e.code : null)
++ || (internal ? o.internalCode || 'internal.error' : 'request.invalid');
++ const detail = internal ? (o.internalDetail || 'Internal error') : (e && (e.detail || e.message)) || undefined;
++ const raw = known && known.extra ? known.extra : null;
++ let extra;
++ if (raw && o.extra === 'details') extra = status < 500 ? { details: raw } : undefined;
++ else if (raw) extra = raw;
++ return sendProblem(res, status, code, { detail, ctx: req && req.ov, extra });
++}
++
++function sendJson(res, status, body) {
++ if (typeof res.status === 'function' && typeof res.json === 'function') return res.status(status).json(body);
++ res.statusCode = status;
++ res.setHeader('Content-Type', 'application/json; charset=utf-8');
++ res.end(JSON.stringify(body));
++ return res;
++}
++
++/**
++ * A JSON handler: its return value is the body (status a number or (out) => number); undefined means the handler
++ * answered itself. Errors become problems through sendError.
++ */
++function run(fn, status = 200, opts) {
++ const o = options(opts);
++ return async (req, res) => {
++ try {
++ const out = await fn(req, res);
++ if (out === undefined || res.headersSent) return;
++ if (o.noStore) privateNoStore(res);
++ sendJson(res, typeof status === 'function' ? status(out) : status, out);
++ } catch (err) {
++ sendError(res, req, err, o);
++ }
++ };
++}
++
++/** A handler that answers itself (Tips/VIP wrap): a throw or a rejection becomes a problem through sendError. */
++function wrap(fn, opts) {
++ const o = options(opts);
++ return async (req, res, next) => {
++ try { await fn(req, res, next); } catch (err) { sendError(res, req, err, o); }
++ };
++}
++
++const UNITS = { b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3 };
++function bytes(v) {
++ if (typeof v === 'number') return v;
++ const m = /^\s*(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?\s*$/i.exec(String(v));
++ if (!m) throw new TypeError(`jsonBody: limit ${JSON.stringify(v)} is not a size ('512kb', '1mb' or a number of bytes)`);
++ return Math.floor(Number(m[1]) * UNITS[(m[2] || 'b').toLowerCase()]);
++}
++
++const JSON_TYPE = /^application\/(?:[\w.+-]+\+)?json\s*(?:;|$)/i;
++
++/**
++ * A JSON body parser whose failures are problems: a malformed body → 400 'request.invalid_json' ('Malformed JSON
++ * body'), a body over `limit` → 413 'request.too_large', an encoding it cannot read → 415. Like express.json():
++ * only application/json (and +json) bodies, objects and arrays only, an empty body is {}, a body parsed already is
++ * left alone. `parser: express.json({ limit })` uses that parser instead and maps its errors the same way.
++ */
++function jsonBody(opts = {}) {
++ const o = options(opts);
++ const max = bytes(o.limit == null ? '512kb' : o.limit);
++ const fail = (req, res, err) => {
++ if (err && (err.type === 'entity.too.large' || err.status === 413)) return sendProblem(res, 413, 'request.too_large', { detail: `The body is larger than ${max} bytes`, ctx: req.ov });
++ if (err && err.status === 415) return sendProblem(res, 415, 'request.unsupported_encoding', { detail: err.message, ctx: req.ov });
++ return sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov });
++ };
++ if (typeof o.parser === 'function') {
++ return function jsonBodyParser(req, res, next) { o.parser(req, res, (err) => (err ? fail(req, res, err) : next())); };
++ }
++ return function jsonBodyParser(req, res, next) {
++ if (req._body) return next();
++ if (req.body === undefined) req.body = {};
++ const te = req.headers['transfer-encoding'];
++ const len = req.headers['content-length'];
++ if (te === undefined && (len === undefined || Number.isNaN(Number(len)))) return next();
++ if (!JSON_TYPE.test(String(req.headers['content-type'] || ''))) return next();
++ if (len !== undefined && Number(len) > max) return fail(req, res, { type: 'entity.too.large' });
++ const enc = String(req.headers['content-encoding'] || 'identity').toLowerCase();
++ const zlib = require('node:zlib');
++ const decoders = { identity: null, gzip: zlib.createGunzip, deflate: zlib.createInflate, br: zlib.createBrotliDecompress };
++ if (!(enc in decoders)) return fail(req, res, { status: 415, message: `Content-Encoding ${enc} is not supported` });
++ const stream = decoders[enc] ? req.pipe(decoders[enc]()) : req;
++ const chunks = [];
++ let size = 0;
++ let done = false;
++ const end = (err) => {
++ if (done) return;
++ done = true;
++ if (err) return fail(req, res, err);
++ const text = Buffer.concat(chunks).toString('utf8').replace(/^/, '');
++ if (!text.trim()) { req.body = {}; req._body = true; return next(); }
++ const first = text.trimStart()[0];
++ if (first !== '{' && first !== '[') return fail(req, res, { type: 'entity.parse.failed' });
++ try { req.body = JSON.parse(text); } catch { return fail(req, res, { type: 'entity.parse.failed' }); }
++ req._body = true;
++ return next();
++ };
++ stream.on('data', (c) => {
++ if (done) return;
++ size += c.length;
++ if (size > max) { end({ type: 'entity.too.large' }); return; }
++ chunks.push(c);
++ });
++ stream.on('end', () => end());
++ stream.on('error', () => end({ type: 'entity.parse.failed' }));
++ req.on('aborted', () => { done = true; });
++ return undefined;
++ };
++}
++
++function vary(res, field) {
++ if (typeof res.vary === 'function') return res.vary(field);
++ const have = String(res.getHeader('Vary') || '').split(',').map((s) => s.trim()).filter(Boolean);
++ if (have.includes('*') || have.some((h) => h.toLowerCase() === field.toLowerCase())) return res;
++ res.setHeader('Vary', [...have, field].join(', '));
++ return res;
++}
++
++/** Private, per-viewer responses: never stored by a shared cache (Cache-Control: private, no-store; Vary: Cookie, Authorization). */
++function privateNoStore(res) {
++ res.setHeader('Cache-Control', 'private, no-store');
++ vary(res, 'Cookie');
++ vary(res, 'Authorization');
++ return res;
++}
++
++/**
++ * Opt-in terminal middleware (Bot's server/app.js), mounted last: app.use(jsonErrors()). Under `apiPrefix` (a string or
++ * a list; default ['/api/', '/internal/']) a miss is 404 'not_found' and an error a problem: a malformed body 400
++ * 'request.invalid_json', too large 413 'request.too_large', a ServiceError (or `map`) its own, anything else
++ * through sendError. Elsewhere (pages) the answers are text/plain `notFoundText` / `errorText`. `notFound: false`
++ * leaves the 404 out. Returns [notFound, errorHandler], which app.use() takes as is.
++ */
++function jsonErrors(opts = {}) {
++ const o = options(opts);
++ const prefixes = [].concat(o.apiPrefix == null ? ['/api/', '/internal/'] : o.apiPrefix);
++ const isApi = (req) => prefixes.some((p) => String(req.path || req.url || '').startsWith(p));
++ const notFoundText = o.notFoundText || 'Not found\n';
++ const errorText = o.errorText || 'Something went wrong\n';
++ const text = (res, status, body) => { res.statusCode = status; res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.end(body); };
++
++ function notFound(req, res) {
++ if (isApi(req)) return sendProblem(res, 404, 'not_found', { ctx: req.ov });
++ return text(res, 404, notFoundText);
++ }
++ // eslint-disable-next-line no-unused-vars
++ function errorHandler(err, req, res, next) {
++ const api = isApi(req);
++ if (api && !res.headersSent && err && err.type === 'entity.parse.failed') return sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov });
++ if (api && !res.headersSent && err && err.type === 'entity.too.large') return sendProblem(res, 413, 'request.too_large', { ctx: req.ov });
++ if (api) return sendError(res, req, err, o);
++ (o.log || console).error(`[${o.name || 'service'}]`, err && err.stack ? err.stack : err);
++ if (res.headersSent) return undefined;
++ return text(res, 500, errorText);
++ }
++ const out = o.notFound === false ? [errorHandler] : [notFound, errorHandler];
++ out.notFound = notFound;
++ out.errorHandler = errorHandler;
++ return out;
++}
++
++module.exports = { ServiceError, createServiceError, asServiceError, sendError, run, wrap, jsonBody, privateNoStore, jsonErrors };
ddiff --git a/src/service/graceful.js b/src/service/graceful.js
nnew file mode 100644
iindex 0000000..6b248f4
---- /dev/null
++++ b/src/service/graceful.js
@@@ -0,0 +1,162 @@
++'use strict';
++/**
++ * Graceful stop: the module OpenVibe.Network and OpenVibe.Community each carried a copy of (server/graceful.js), with
++ * the options the other services' hand-written shutdowns need. systemd sends SIGTERM on a restart or a deploy; SIGINT
++ * does the same by hand. On the first signal:
++ *
++ * 0. stopping() turns true at once (a readiness check can answer 503 from here on);
++ * 1. `stop` steps run in order: timers, pollers and relays stop taking new work (nothing new starts);
++ * 2. `beforeDrain(signal)` runs: the last moment the server still takes connections (say goodbye on a socket,
++ * wait for a load balancer to see the readiness flip);
++ * 3. the HTTP server stops taking connections, requests in flight are answered with `Connection: close`, idle
++ * keep-alive connections are closed (swept every 50 ms), and open event streams (text/event-stream) are
++ * destroyed: EventSource reconnects on its own, to another process;
++ * 4. the requests in flight finish, for at most `drainMs`; whatever is still open then is cut;
++ * 5. `close` steps run in order (the outbox relay settles, analytics flush, the database closes), then each of
++ * `handles` is closed;
++ * 6. exit(0), or exit(1) when a handle failed to close.
++ *
++ * A step that throws is logged and the stop goes on. A hard `deadlineMs` bounds the whole stop: past it the process
++ * exits with `deadlineExitCode` (1, as Network and Community; the 5 s family and Media exit 0). A second signal while
++ * stopping changes nothing. exit is called once.
++ */
++
++const DRAIN_MS = 4000;
++const DEADLINE_MS = 5000;
++const SWEEP_MS = 50;
++
++const EVENT_STREAM = /text\/event-stream/i;
++
++function isEventStream(res) {
++ if (EVENT_STREAM.test(String(res.getHeader('content-type') || ''))) return true;
++ return typeof res._header === 'string' && /content-type:\s*text\/event-stream/i.test(res._header);
++}
++
++/** A handle's way to close: a function, or an object with close() (else stop(), end(), quit()). */
++function closer(h) {
++ if (typeof h === 'function') return h;
++ for (const m of ['close', 'stop', 'end', 'quit']) if (h && typeof h[m] === 'function') return () => h[m]();
++ return null;
++}
++
++/**
++ * gracefulStop({ name, server, stop, close, drainMs, deadlineMs, deadlineExitCode, signals, exit, log, beforeDrain, handles })
++ * -> { stop(signal?): Promise<number>, stopping(): boolean }
++ * stop() resolves with the exit code it passed to exit (the deadline's, when that fired first).
++ */
++function gracefulStop(o = {}) {
++ const name = o.name || 'service';
++ const server = o.server || null;
++ const drainMs = o.drainMs == null ? DRAIN_MS : o.drainMs;
++ const deadlineMs = o.deadlineMs == null ? DEADLINE_MS : o.deadlineMs;
++ const deadlineExitCode = o.deadlineExitCode == null ? 1 : o.deadlineExitCode;
++ const log = o.log || console;
++ const exit = o.exit || ((code) => process.exit(code));
++ const handles = o.handles == null ? [] : [].concat(o.handles);
++ let started = null;
++
++ // Every response in flight, so the stop can mark them Connection: close and destroy event streams.
++ const inflight = new Set();
++ if (server) {
++ server.prependListener('request', (req, res) => {
++ if (started && !res.headersSent) res.setHeader('Connection', 'close');
++ inflight.add(res);
++ res.on('close', () => inflight.delete(res));
++ });
++ }
++
++ async function step(fn, phase) {
++ try { await fn(); } catch (err) { log.warn(`[${name}] stop: ${phase} step failed: ${err && err.message}`); }
++ }
++
++ function drain() {
++ if (!server) return Promise.resolve(0);
++ return new Promise((resolve) => {
++ let done = false;
++ let sweep = null;
++ let timer = null;
++ const finish = (cut) => {
++ if (done) return;
++ done = true;
++ clearInterval(sweep);
++ clearTimeout(timer);
++ resolve(cut);
++ };
++ server.close(() => finish(0));
++ for (const res of inflight) {
++ if (isEventStream(res)) res.destroy();
++ else if (!res.headersSent) res.setHeader('Connection', 'close');
++ }
++ // A connection whose response just finished goes idle: close it now rather than after keepAliveTimeout.
++ const idle = () => { if (typeof server.closeIdleConnections === 'function') server.closeIdleConnections(); };
++ idle();
++ sweep = setInterval(idle, SWEEP_MS);
++ timer = setTimeout(() => {
++ const cut = inflight.size;
++ if (typeof server.closeAllConnections === 'function') server.closeAllConnections();
++ finish(cut);
++ }, drainMs);
++ });
++ }
++
++ function stop(signal = 'stop') {
++ if (started) return started;
++ let settle;
++ let exited = false;
++ started = new Promise((resolve) => { settle = resolve; });
++ const finish = (code) => {
++ if (exited) return;
++ exited = true;
++ settle(code);
++ exit(code);
++ };
++ const t0 = Date.now();
++ const hard = setTimeout(() => {
++ log.error(`[${name}] stop took longer than ${deadlineMs} ms: exiting ${deadlineExitCode}`);
++ finish(deadlineExitCode);
++ }, deadlineMs);
++ // Not unref()'d (Network's copy did): a step stuck on a promise with nothing else alive would let the process
++ // drain and exit 0 on its own, and the deadline is what decides the exit code.
++ (async () => {
++ log.log(`[${name}] ${signal}: stopping (requests in flight get ${drainMs} ms)`);
++ for (const fn of o.stop || []) await step(fn, 'stop');
++ if (typeof o.beforeDrain === 'function') await step(() => o.beforeDrain(signal), 'beforeDrain');
++ const cut = await drain();
++ if (cut) log.warn(`[${name}] ${cut} request(s) still open after ${drainMs} ms were cut`);
++ for (const fn of o.close || []) await step(fn, 'close');
++ let code = 0;
++ for (const h of handles) {
++ const fn = closer(h);
++ if (!fn) continue;
++ try { await fn(); } catch (err) { code = 1; log.error(`[${name}] stop: a handle failed to close: ${err && err.message}`); }
++ }
++ clearTimeout(hard);
++ if (exited) return;
++ log.log(`[${name}] stopped in ${Date.now() - t0} ms`);
++ finish(code);
++ })().catch((err) => { // only a throwing logger lands here
++ clearTimeout(hard);
++ finish(1);
++ if (log !== console) console.error(`[${name}] stop failed:`, err);
++ });
++ return started;
++ }
++
++ if (o.signals !== false) {
++ process.on('SIGTERM', () => { stop('SIGTERM'); }); // floating-ok: stop() never rejects
++ process.on('SIGINT', () => { stop('SIGINT'); }); // floating-ok: stop() never rejects
++ }
++ return { stop, stopping: () => !!started };
++}
++
++/**
++ * Await `promise`, but no longer than `ms` (a best-effort step inside the deadline). A rejection is swallowed:
++ * within(3000, mirror.flush()) is "flush if it can", never a reason to stop the stop.
++ */
++function within(ms, promise) {
++ let t = null;
++ const timeout = new Promise((r) => { t = setTimeout(r, ms); t.unref(); });
++ return Promise.race([Promise.resolve(promise).catch(() => {}), timeout]).finally(() => clearTimeout(t));
++}
++
++module.exports = { gracefulStop, within, DRAIN_MS, DEADLINE_MS };
ddiff --git a/src/service/index.js b/src/service/index.js
nnew file mode 100644
iindex 0000000..2d0cceb
---- /dev/null
++++ b/src/service/index.js
@@@ -0,0 +1,23 @@
++'use strict';
++/**
++ * openvibe-sdk/service: the layer every service wrote around the shared observability and problem helpers (Node only).
++ *
++ * const svc = require('openvibe-sdk/service');
++ * const ApiError = svc.createServiceError('ApiError');
++ * router.get('/api/posts/:id', svc.run(async (req) => posts.get(req.params.id), 200, { name: 'Blog API' }));
++ * router.post('/api/posts', svc.jsonBody({ limit: '512kb' }), svc.run(create, 201));
++ * app.use(svc.jsonErrors()); // opt-in: 404 + error mapping, mounted last
++ * const server = app.listen(port);
++ * svc.gracefulStop({ name: 'Blog', server, stop: [() => worker.stop()], close: [() => outbox.stop(), () => db.close()] });
++ *
++ * Readiness, metrics, /release.json and the problem body are re-exported from openvibe-shared and
++ * openvibe-contracts (./shared.js), loaded on first use. docs/service.md has the adoption recipe per service family.
++ */
++const shared = require('./shared');
++const graceful = require('./graceful');
++const errors = require('./errors');
++
++const out = { ...graceful, ...errors };
++for (const name of Object.keys(shared)) Object.defineProperty(out, name, Object.getOwnPropertyDescriptor(shared, name));
++
++module.exports = out;
ddiff --git a/src/service/shared.js b/src/service/shared.js
nnew file mode 100644
iindex 0000000..e9c655c
---- /dev/null
++++ b/src/service/shared.js
@@@ -0,0 +1,51 @@
++'use strict';
++/**
++ * The observability and problem helpers every service already calls, re-exported and never copied:
++ *
++ * createReadiness, skip, safeReason openvibe-shared/ready
++ * createRegistry, instrument, metricsHandler, isLoopbackDirect, releaseInfo openvibe-shared/metrics
++ * createRelease openvibe-shared/release
++ * problem, sendProblem openvibe-contracts (http)
++ *
++ * The SDK depends on neither package (a service has both), so each name is a getter that requires its package on
++ * first use, from the service's own node_modules: the same module instance the service's other code gets. Without
++ * the package the getter throws an Error that names it.
++ */
++
++const SOURCES = {
++ 'openvibe-shared/ready': { pkg: 'openvibe-shared', names: ['createReadiness', 'skip', 'safeReason'] },
++ 'openvibe-shared/metrics': { pkg: 'openvibe-shared', names: ['createRegistry', 'instrument', 'metricsHandler', 'isLoopbackDirect', 'releaseInfo'] },
++ 'openvibe-shared/release': { pkg: 'openvibe-shared', names: ['createRelease'] },
++ 'openvibe-contracts': { pkg: 'openvibe-contracts', names: ['problem', 'sendProblem'], pick: (m) => m.http },
++};
++
++const loaded = new Map();
++
++function load(spec, name) {
++ if (loaded.has(spec)) return loaded.get(spec);
++ const { pkg, pick } = SOURCES[spec];
++ let mod;
++ try {
++ mod = require(spec);
++ } catch (err) {
++ if (err && err.code === 'MODULE_NOT_FOUND' && String(err.message).includes(`'${spec}'`)) {
++ const e = new Error(`openvibe-sdk/service: ${name} comes from ${spec}; install \`${pkg}\` in the service (the SDK does not depend on it)`);
++ e.code = 'sdk.missing_dependency';
++ e.package = pkg;
++ throw e;
++ }
++ throw err;
++ }
++ const out = pick ? pick(mod) : mod;
++ loaded.set(spec, out);
++ return out;
++}
++
++const lazy = {};
++for (const [spec, { names }] of Object.entries(SOURCES)) {
++ for (const name of names) {
++ Object.defineProperty(lazy, name, { enumerable: true, configurable: false, get: () => load(spec, name)[name] });
++ }
++}
++
++module.exports = lazy;
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex 894f663..9d97734 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -76,12 +76,12 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
assert.deepEqual(problems, []);
-- assert.deepEqual(blocked.sort(), ['./cache', './db', './events', './govern', './identity', './limits', './openre', './placement', './pubsub', './queue', './sso', './testing', './valkey', './vip']);
++ assert.deepEqual(blocked.sort(), ['./cache', './db', './events', './govern', './identity', './limits', './openre', './placement', './pubsub', './queue', './service', './sso', './testing', './valkey', './vip']);
const rel = [...scanned].map((f) => path.relative(ROOT, f));
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
}
-- for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/identity.js', 'src/sso/index.js', 'src/service-outbox.js', 'src/testing/index.js']) {
++ for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/identity.js', 'src/sso/index.js', 'src/service/index.js', 'src/service-outbox.js', 'src/testing/index.js']) {
assert.ok(!rel.includes(never), `${never} is not reachable from a browser entry`);
}
console.log(` scanned ${scanned.size} files`);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex badc9e7..7a28d91 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -22,7 +22,7 @@ run([
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8' });
const info = JSON.parse(out)[0];
const files = info.files.map((f) => f.path);
-- for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'browser/openvibe-sdk.mjs']) {
++ for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'browser/openvibe-sdk.mjs']) {
assert.ok(files.includes(must), `tarball contains ${must}`);
}
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
@@@ -66,7 +66,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 31);
++ assert.equal(subpaths.length, 32);
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/test/service.test.js b/test/service.test.js
nnew file mode 100644
iindex 0000000..706ed86
---- /dev/null
++++ b/test/service.test.js
@@@ -0,0 +1,458 @@
++'use strict';
++/**
++ * openvibe-sdk/service: gracefulStop against a real http.Server on port 0 (exit injected, no signal handlers), the
++ * error helpers' problem+json answers, jsonBody, jsonErrors and the lazy re-exports.
++ */
++const assert = require('node:assert/strict');
++const { EventEmitter } = require('node:events');
++const http = require('node:http');
++const path = require('node:path');
++const zlib = require('node:zlib');
++const { pathToFileURL } = require('node:url');
++const { run, sleep, waitFor } = require('./helpers');
++const svc = require('../src/service');
++
++const quiet = { log() {}, warn() {}, error() {} };
++
++function recorder() {
++ const lines = [];
++ return { lines, log: (...a) => lines.push(['log', a.join(' ')]), warn: (...a) => lines.push(['warn', a.join(' ')]), error: (...a) => lines.push(['error', a.join(' ')]) };
++}
++
++async function listen(handler) {
++ const server = http.createServer(handler);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ return { server, url: `http://127.0.0.1:${server.address().port}` };
++}
++
++/** GET/POST with node:http so the Connection header and the socket are visible. */
++function request(url, { method = 'GET', agent, headers = {}, body, onResponse } = {}) {
++ return new Promise((resolve, reject) => {
++ const req = http.request(url, { method, agent, headers }, (res) => {
++ if (onResponse) onResponse(res);
++ const chunks = [];
++ res.on('data', (c) => chunks.push(c));
++ res.on('end', () => {
++ const text = Buffer.concat(chunks).toString('utf8');
++ let json = null;
++ try { json = JSON.parse(text); } catch { /* not JSON */ }
++ resolve({ status: res.statusCode, headers: res.headers, text, json });
++ });
++ res.on('error', reject);
++ });
++ req.on('error', reject);
++ req.end(body);
++ });
++}
++
++let contracts = null;
++try { contracts = require('openvibe-contracts'); } catch { /* the error tests skip */ }
++
++const errorTests = [
++ ['run: the return value is the body; refusals are problems with status, code, detail and extra spread', async () => {
++ const log = recorder();
++ const ApiError = svc.createServiceError('ApiError');
++ const o = { name: 'Blog API', log };
++ const { server, url } = await listen((req, res) => {
++ req.ov = { requestId: 'req_1', traceId: 't'.repeat(32) };
++ const routes = {
++ '/ok': svc.run(async () => ({ hi: 1 }), 200, o),
++ '/created': svc.run(async () => ({ id: 'p1' }), (out) => (out.id ? 201 : 200), o),
++ '/missing': svc.run(async () => { throw new ApiError(404, 'post.not_found', 'No such post', { id: 'p1' }); }, 200, o),
++ '/boom': svc.run(async () => { throw new Error('db password leaked in message'); }, 200, o),
++ '/busy': svc.run(async () => { throw new ApiError(503, 'search.unavailable', 'Try later'); }, 200, o),
++ '/bad-gateway': svc.run(async () => { throw new ApiError(502, 'billing.unreachable', 'Billing is down'); }, 200, o),
++ '/plain-400': svc.run(async () => { throw Object.assign(new Error('nope'), { status: 400 }); }, 200, o),
++ '/self': svc.run(async (rq, rs) => { rs.statusCode = 204; rs.end(); }, 200, o),
++ '/nostore': svc.run(async () => ({ me: 1 }), 200, { ...o, noStore: true }),
++ '/reviews-log': svc.run(async () => { throw new Error('x'); }, 200, log),
++ };
++ routes[req.url](req, res);
++ });
++ try {
++ let r = await request(`${url}/ok`);
++ assert.equal(r.status, 200);
++ assert.deepEqual(r.json, { hi: 1 });
++ assert.match(r.headers['content-type'], /^application\/json/);
++ assert.equal((await request(`${url}/created`)).status, 201);
++
++ r = await request(`${url}/missing`);
++ assert.equal(r.status, 404);
++ assert.equal(r.headers['content-type'], 'application/problem+json');
++ assert.equal(r.json.code, 'post.not_found');
++ assert.equal(r.json.detail, 'No such post');
++ assert.equal(r.json.id, 'p1', 'extra is spread by default');
++ assert.equal(r.json.request_id, 'req_1');
++ assert.equal(r.json.type, 'https://openvibe.network/problems/post.not_found');
++ assert.equal(log.lines.length, 0, 'a refusal is not logged');
++
++ r = await request(`${url}/boom`);
++ assert.equal(r.status, 500);
++ assert.equal(r.json.code, 'internal.error');
++ assert.equal(r.json.detail, 'Internal error');
++ assert.ok(!r.text.includes('password'));
++ assert.equal(log.lines.length, 1);
++ assert.match(log.lines[0][1], /^\[Blog API\] Error: db password/);
++
++ r = await request(`${url}/busy`);
++ assert.equal(r.status, 503);
++ assert.equal(r.json.code, 'search.unavailable');
++ assert.equal(r.json.detail, 'Try later');
++ assert.equal(log.lines.length, 1, '503 is not logged');
++ r = await request(`${url}/bad-gateway`);
++ assert.equal(r.status, 502);
++ assert.equal(r.json.detail, 'Billing is down', 'only 500 hides the detail');
++ assert.equal(log.lines.length, 2, 'other 5xx are logged');
++
++ r = await request(`${url}/plain-400`);
++ assert.equal(r.status, 400);
++ assert.equal(r.json.code, 'request.invalid');
++ assert.equal(r.json.detail, 'nope');
++
++ assert.equal((await request(`${url}/self`)).status, 204);
++ r = await request(`${url}/nostore`);
++ assert.equal(r.headers['cache-control'], 'private, no-store');
++ assert.equal(r.headers.vary, 'Cookie, Authorization');
++
++ r = await request(`${url}/reviews-log`);
++ assert.equal(r.status, 500);
++ assert.match(log.lines.at(-1)[1], /^\[service\]/, 'a logger as the third argument (Reviews run(fn, status, log))');
++ } finally {
++ server.close();
++ }
++ }],
++
++ ['sendError/wrap: extra under details (Tips/VIP), internalCode/internalDetail, map, headers already sent', async () => {
++ const TipsError = svc.createServiceError('TipsError');
++ const tips = { name: 'Tips', log: quiet, extra: 'details', internalCode: 'tips.internal', internalDetail: 'internal error' };
++ const inputError = (e) => (e && e.name === 'ValidationError' ? new TipsError(422, 'tips.invalid', e.message) : null);
++ const { server, url } = await listen((req, res) => {
++ const routes = {
++ '/details': svc.wrap(async () => { throw new TipsError(409, 'tips.conflict', 'Already paid', { tip: 't1' }); }, tips),
++ '/details-500': svc.wrap(async () => { throw new TipsError(502, 'tips.upstream', 'Billing', { tip: 't1' }); }, tips),
++ '/internal': svc.wrap(async () => { throw new Error('boom'); }, tips),
++ '/mapped': svc.wrap(async () => { throw Object.assign(new Error('amount must be positive'), { name: 'ValidationError' }); }, { ...tips, map: inputError }),
++ '/sent': svc.wrap(async (rq, rs) => { rs.writeHead(200); rs.write('partial'); throw new Error('late'); }, tips),
++ '/reviews': (rq, rs) => svc.sendError(rs, rq, new TipsError(403, 'tips.denied'), quiet),
++ '/pg-code': (rq, rs) => svc.sendError(rs, rq, Object.assign(new Error('dup'), { code: '23505' }), quiet),
++ };
++ routes[req.url](req, res);
++ if (req.url === '/sent') setTimeout(() => res.end(), 20);
++ });
++ try {
++ let r = await request(`${url}/details`);
++ assert.equal(r.status, 409);
++ assert.deepEqual(r.json.details, { tip: 't1' });
++ assert.equal(r.json.tip, undefined);
++ r = await request(`${url}/details-500`);
++ assert.equal(r.json.details, undefined, 'Tips nests extra only below 500');
++ r = await request(`${url}/internal`);
++ assert.equal(r.json.code, 'tips.internal');
++ assert.equal(r.json.detail, 'internal error');
++ r = await request(`${url}/mapped`);
++ assert.equal(r.status, 422);
++ assert.equal(r.json.code, 'tips.invalid');
++ r = await request(`${url}/sent`);
++ assert.equal(r.status, 200);
++ assert.equal(r.text, 'partial');
++ r = await request(`${url}/reviews`);
++ assert.equal(r.status, 403);
++ assert.equal(r.json.code, 'tips.denied');
++ assert.equal(r.json.detail, 'tips.denied', 'no detail: the message, which is the code (Reviews)');
++ r = await request(`${url}/pg-code`);
++ assert.equal(r.status, 500);
++ assert.equal(r.json.code, 'internal.error', "an unexpected error's own code never reaches the body");
++ } finally {
++ server.close();
++ }
++ }],
++
++ ['createServiceError / asServiceError: the class, other services\' errors, publishing errors and TypeErrors', async () => {
++ const ApiError = svc.createServiceError('ApiError');
++ const e = new ApiError(404, 'post.not_found', 'No such post');
++ assert.ok(e instanceof Error);
++ assert.equal(ApiError.name, 'ApiError');
++ assert.equal(e.name, 'ApiError');
++ assert.deepEqual([e.status, e.code, e.detail, e.message, e.extra], [404, 'post.not_found', 'No such post', 'No such post', null]);
++ assert.equal(new ApiError(400, 'x.y').message, 'x.y');
++ assert.equal(svc.asServiceError(e), e);
++ const other = Object.assign(new Error('Over quota'), { status: 429, code: 'billing.quota' });
++ assert.equal(svc.asServiceError(other), other, 'any error with an HTTP status and a code');
++ assert.equal(svc.asServiceError(new Error('x')), null);
++ assert.equal(svc.asServiceError(Object.assign(new Error('pg'), { code: '23505' })), null);
++
++ const pub = Object.assign(new Error('Stale revision'), { name: 'PublishingError', status: 409, code: 'revision.conflict', expected: 3, current: 4 });
++ assert.equal(svc.asServiceError(Object.assign(new Error('x'), { name: 'PublishingError', status: 409 })), null, 'publishing is opt-in');
++ const mapped = svc.asServiceError(pub, { publishing: true, ServiceError: ApiError });
++ assert.ok(mapped instanceof ApiError);
++ assert.deepEqual([mapped.status, mapped.code, mapped.message, mapped.extra], [409, 'revision.conflict', 'Stale revision', { expected: 3, current: 4 }]);
++ const te = svc.asServiceError(new TypeError('title must be a string'), { publishing: true });
++ assert.deepEqual([te.status, te.code, te.message], [422, 'request.invalid', 'title must be a string']);
++ assert.equal(svc.asServiceError(new TypeError("Cannot read properties of undefined (reading 'x')"), { publishing: true }), null, 'a bug stays a 500');
++ }],
++
++ ['jsonBody: malformed JSON is 400 request.invalid_json; too large 413; only JSON bodies; strict; gzip; a given parser', async () => {
++ const seen = [];
++ const small = svc.jsonBody({ limit: 64 });
++ const def = svc.jsonBody();
++ const given = svc.jsonBody({ parser: (req, res, next) => next(Object.assign(new Error('Unexpected token'), { type: 'entity.parse.failed', status: 400 })) });
++ const { server, url } = await listen((req, res) => {
++ const mw = req.url === '/small' ? small : req.url === '/given' ? given : def;
++ mw(req, res, () => { seen.push(req.body); res.end('ok'); });
++ });
++ const post = (p, body, headers = { 'Content-Type': 'application/json' }) => request(`${url}${p}`, { method: 'POST', body, headers });
++ try {
++ let r = await post('/', '{"a":');
++ assert.equal(r.status, 400);
++ assert.equal(r.headers['content-type'], 'application/problem+json');
++ assert.equal(r.json.code, 'request.invalid_json');
++ assert.equal(r.json.detail, 'Malformed JSON body');
++ assert.equal((await post('/', '"just a string"')).json.code, 'request.invalid_json', 'objects and arrays only, as express.json()');
++
++ r = await post('/', '{"a":[1,2]}');
++ assert.equal(r.text, 'ok');
++ assert.deepEqual(seen.pop(), { a: [1, 2] });
++ await post('/', '{"a":1}', { 'Content-Type': 'application/merge-patch+json; charset=utf-8' });
++ assert.deepEqual(seen.pop(), { a: 1 });
++ await post('/', '', { 'Content-Type': 'application/json' });
++ assert.deepEqual(seen.pop(), {});
++ await post('/', 'a=1', { 'Content-Type': 'application/x-www-form-urlencoded' });
++ assert.deepEqual(seen.pop(), {}, 'not JSON: left for another parser');
++ await post('/', zlib.gzipSync('{"z":true}'), { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' });
++ assert.deepEqual(seen.pop(), { z: true });
++
++ r = await post('/small', JSON.stringify({ text: 'x'.repeat(100) }));
++ assert.equal(r.status, 413);
++ assert.equal(r.json.code, 'request.too_large');
++ r = await post('/small', JSON.stringify({ text: 'x'.repeat(100) }), { 'Content-Type': 'application/json', 'Transfer-Encoding': 'chunked' });
++ assert.equal(r.status, 413, 'a chunked body is counted as it arrives');
++ r = await post('/', '{}', { 'Content-Type': 'application/json', 'Content-Encoding': 'compress' });
++ assert.equal(r.status, 415);
++
++ r = await post('/given', '{');
++ assert.equal(r.status, 400);
++ assert.equal(r.json.code, 'request.invalid_json');
++ assert.throws(() => svc.jsonBody({ limit: 'lots' }), /not a size/);
++ } finally {
++ server.close();
++ }
++ }],
++
++ ['jsonErrors: problems under /api/ and /internal/, text/plain for pages', async () => {
++ const log = recorder();
++ const [notFound, errorHandler] = svc.jsonErrors({ name: 'Bot', log });
++ const ApiError = svc.createServiceError('ApiError');
++ const errors = {
++ '/api/parse': Object.assign(new SyntaxError('x'), { type: 'entity.parse.failed', status: 400 }),
++ '/api/big': Object.assign(new Error('x'), { type: 'entity.too.large', status: 413 }),
++ '/internal/refused': new ApiError(422, 'bot.refused', 'No'),
++ '/api/boom': new Error('boom'),
++ '/page/boom': new Error('page boom'),
++ };
++ const { server, url } = await listen((req, res) => {
++ req.path = req.url;
++ if (errors[req.url]) return errorHandler(errors[req.url], req, res, () => {});
++ return notFound(req, res);
++ });
++ try {
++ let r = await request(`${url}/api/nothing`);
++ assert.equal(r.status, 404);
++ assert.equal(r.json.code, 'not_found');
++ r = await request(`${url}/nothing`);
++ assert.equal(r.status, 404);
++ assert.match(r.headers['content-type'], /^text\/plain/);
++ assert.equal(r.text, 'Not found\n');
++ assert.equal((await request(`${url}/api/parse`)).json.code, 'request.invalid_json');
++ assert.equal((await request(`${url}/api/big`)).status, 413);
++ r = await request(`${url}/internal/refused`);
++ assert.deepEqual([r.status, r.json.code, r.json.detail], [422, 'bot.refused', 'No']);
++ r = await request(`${url}/api/boom`);
++ assert.deepEqual([r.status, r.json.code], [500, 'internal.error']);
++ r = await request(`${url}/page/boom`);
++ assert.equal(r.status, 500);
++ assert.equal(r.text, 'Something went wrong\n');
++ assert.equal(log.lines.length, 2);
++ assert.equal(svc.jsonErrors({ notFound: false }).length, 1);
++ } finally {
++ server.close();
++ }
++ }],
++];
++
++run([
++ ['gracefulStop: in flight gets Connection: close and finishes, idle keep-alive closed, SSE destroyed, steps in order', async () => {
++ const order = [];
++ let release;
++ const slowGate = new Promise((r) => { release = r; });
++ let slowArrived = false;
++ let idleSocket = null;
++ const { server, url } = await listen((req, res) => {
++ if (req.url === '/fast') { idleSocket = req.socket; return res.end('fast'); }
++ if (req.url === '/sse') { res.writeHead(200, { 'Content-Type': 'text/event-stream' }); return res.write(': hi\n\n'); }
++ slowArrived = true;
++ res.on('finish', () => order.push('slow finished'));
++ return slowGate.then(() => res.end('done'));
++ });
++ server.keepAliveTimeout = 65000;
++ const exits = [];
++ const g = svc.gracefulStop({
++ name: 'T', server, signals: false, log: quiet, exit: (c) => exits.push(c),
++ stop: [() => order.push('stop 1'), async () => { await sleep(5); order.push('stop 2'); }],
++ beforeDrain: (signal) => order.push(`beforeDrain ${signal}`),
++ close: [async () => { await sleep(5); order.push('close 1'); }, () => order.push('close 2')],
++ handles: { close: async () => order.push('handle') },
++ });
++ const agent = new http.Agent({ keepAlive: true, maxSockets: 10 });
++ try {
++ const fast = await request(`${url}/fast`, { agent });
++ assert.equal(fast.text, 'fast');
++ assert.ok(idleSocket && !idleSocket.destroyed, 'the keep-alive socket is idle and open');
++ let sseClosed = false;
++ const sseOpen = new Promise((resolve) => {
++ const rq = http.get(`${url}/sse`, { agent: false }, (res) => {
++ res.once('data', resolve);
++ res.on('close', () => { sseClosed = true; });
++ res.on('error', () => {});
++ });
++ rq.on('error', () => { sseClosed = true; });
++ });
++ await sseOpen;
++ const slow = request(`${url}/slow`, { agent: false });
++ await waitFor(() => slowArrived);
++
++ assert.equal(g.stopping(), false);
++ const stopped = g.stop('SIGTERM');
++ assert.equal(g.stopping(), true, 'stopping() turns true at once (a readiness flip)');
++ await waitFor(() => idleSocket.destroyed);
++ await waitFor(() => sseClosed);
++ assert.ok(!order.includes('close 1'), 'close steps wait for the drain');
++ release();
++ const r = await slow;
++ assert.equal(r.text, 'done');
++ assert.equal(r.headers.connection, 'close');
++ assert.equal(await stopped, 0);
++ assert.deepEqual(order, ['stop 1', 'stop 2', 'beforeDrain SIGTERM', 'slow finished', 'close 1', 'close 2', 'handle']);
++ assert.deepEqual(exits, [0]);
++ assert.equal(server.listening, false);
++ } finally {
++ agent.destroy();
++ server.closeAllConnections();
++ server.close();
++ }
++ }],
++
++ ['gracefulStop: the deadline exits with deadlineExitCode (1 by default, 0 for the 5 s family), once', async () => {
++ const { server, url } = await listen(() => { /* never answers */ });
++ const exits = [];
++ const log = recorder();
++ const g = svc.gracefulStop({ name: 'Net', server, signals: false, log, exit: (c) => exits.push(c), drainMs: 300, deadlineMs: 100 });
++ const hanging = request(`${url}/hang`, { agent: false }).catch(() => 'cut');
++ await sleep(30);
++ const t0 = Date.now();
++ assert.equal(await g.stop(), 1);
++ assert.ok(Date.now() - t0 < 280, 'the deadline, not the drain');
++ assert.deepEqual(exits, [1]);
++ assert.ok(log.lines.some(([lvl, l]) => lvl === 'error' && /longer than 100 ms: exiting 1/.test(l)));
++ assert.equal(await hanging, 'cut', 'the drain cut it later');
++ await sleep(50);
++ assert.deepEqual(exits, [1], 'exit is called once even when the stop finishes later');
++
++ const exits0 = [];
++ const g0 = svc.gracefulStop({ name: 'Blog', signals: false, log: quiet, exit: (c) => exits0.push(c), deadlineMs: 50, deadlineExitCode: 0, stop: [() => new Promise(() => {})] });
++ assert.equal(await g0.stop('SIGTERM'), 0);
++ assert.deepEqual(exits0, [0]);
++ }],
++
++ ['gracefulStop: a drain timeout with no requests does not log a cut', async () => {
++ const server = new EventEmitter();
++ let closeAllCalls = 0;
++ server.close = () => { /* hold the close callback until the drain timeout */ };
++ server.closeIdleConnections = () => {};
++ server.closeAllConnections = () => { closeAllCalls++; };
++ const log = recorder();
++ const exits = [];
++ const g = svc.gracefulStop({ name: 'T', server, signals: false, log, exit: (c) => exits.push(c), drainMs: 20, deadlineMs: 200 });
++ assert.equal(await g.stop(), 0);
++ assert.equal(closeAllCalls, 1, 'the drain timeout ran');
++ assert.deepEqual(exits, [0]);
++ assert.ok(!log.lines.some(([, line]) => /request\(s\).*cut/.test(line)), 'no request was cut');
++ }],
++
++ ['gracefulStop: a second signal does not start a second stop', async () => {
++ const { server } = await listen((req, res) => res.end());
++ const exits = [];
++ const log = recorder();
++ let steps = 0;
++ const g = svc.gracefulStop({ name: 'T', server, signals: false, log, exit: (c) => exits.push(c), stop: [async () => { steps++; await sleep(20); }] });
++ const a = g.stop('SIGTERM');
++ const b = g.stop('SIGINT');
++ assert.equal(a, b);
++ assert.equal(await a, 0);
++ assert.equal(await g.stop('SIGTERM'), 0);
++ assert.equal(steps, 1);
++ assert.deepEqual(exits, [0]);
++ assert.equal(log.lines.filter(([, l]) => /stopping/.test(l)).length, 1);
++ }],
++
++ ['gracefulStop: a failing step is logged and the stop goes on; a handle that fails to close exits 1', async () => {
++ const exits = [];
++ const log = recorder();
++ const closed = [];
++ const g = svc.gracefulStop({
++ name: 'AI', signals: false, log, exit: (c) => exits.push(c),
++ stop: [() => { throw new Error('poller'); }],
++ close: [() => closed.push('close step')],
++ handles: [() => closed.push('fn'), { close: async () => { throw new Error('db'); } }, { stop: () => closed.push('stop()') }, { quit: async () => closed.push('quit()') }],
++ });
++ assert.equal(await g.stop(), 1);
++ assert.deepEqual(exits, [1]);
++ assert.deepEqual(closed, ['close step', 'fn', 'stop()', 'quit()']);
++ assert.ok(log.lines.some(([lvl, l]) => lvl === 'warn' && /stop step failed: poller/.test(l)));
++ assert.ok(log.lines.some(([lvl, l]) => lvl === 'error' && /handle failed to close: db/.test(l)));
++ }],
++
++ ['within: the value, undefined past ms, a rejection swallowed', async () => {
++ assert.equal(await svc.within(100, Promise.resolve(7)), 7);
++ // within() unrefs its timeout, so keep the test process alive while that timeout is pending.
++ const hold = sleep(30);
++ assert.equal(await svc.within(20, new Promise(() => {})), undefined);
++ await hold;
++ assert.equal(await svc.within(100, Promise.reject(new Error('x'))), undefined);
++ assert.equal(await svc.within(100, 3), 3);
++ assert.deepEqual([svc.DRAIN_MS, svc.DEADLINE_MS], [4000, 5000]);
++ }],
++
++ ['privateNoStore keeps an existing Vary', async () => {
++ const headers = { vary: 'Accept-Encoding' };
++ const res = { getHeader: (k) => headers[k.toLowerCase()], setHeader: (k, v) => { headers[k.toLowerCase()] = v; } };
++ svc.privateNoStore(res);
++ svc.privateNoStore(res);
++ assert.equal(headers['cache-control'], 'private, no-store');
++ assert.equal(headers.vary, 'Accept-Encoding, Cookie, Authorization');
++ }],
++
++ ['re-exports: loaded on first use, the very functions of openvibe-contracts/openvibe-shared, or an error naming the package', async () => {
++ const shared = ['createReadiness', 'skip', 'safeReason', 'createRegistry', 'instrument', 'metricsHandler', 'isLoopbackDirect', 'releaseInfo', 'createRelease'];
++ for (const n of [...shared, 'problem', 'sendProblem']) assert.ok(Object.keys(svc).includes(n), n);
++ let haveShared = true;
++ try { require.resolve('openvibe-shared/ready'); } catch { haveShared = false; }
++ if (haveShared) {
++ assert.equal(svc.createReadiness, require('openvibe-shared/ready').createReadiness);
++ assert.equal(svc.instrument, require('openvibe-shared/metrics').instrument);
++ assert.equal(svc.createRelease, require('openvibe-shared/release').createRelease);
++ } else {
++ for (const n of shared) {
++ assert.throws(() => svc[n], (e) => e.code === 'sdk.missing_dependency' && e.package === 'openvibe-shared' && /install `openvibe-shared`/.test(e.message), n);
++ }
++ }
++ if (contracts) {
++ assert.equal(svc.problem, contracts.http.problem);
++ assert.equal(svc.sendProblem, contracts.http.sendProblem);
++ }
++ const esm = await import(pathToFileURL(path.join(__dirname, '../esm/service.mjs')).href);
++ assert.equal(typeof esm.createReadiness, 'function', 'importing the ESM entry loads neither package');
++ if (contracts) assert.equal(esm.problem(404, 'x.y').code, 'x.y');
++ }],
++
++ ...(contracts ? errorTests : [['error helpers', async () => { console.log('service error helpers: skipped (openvibe-contracts not installed: npm install)'); }]]),
++]);
ddiff --git a/types/service.d.ts b/types/service.d.ts
nnew file mode 100644
iindex 0000000..d6d8af6
---- /dev/null
++++ b/types/service.d.ts
@@@ -0,0 +1,148 @@
++import type { IncomingMessage, Server, ServerResponse } from 'node:http';
++
++type Req = IncomingMessage & Record<string, any>;
++type Res = ServerResponse & Record<string, any>;
++type Next = (err?: unknown) => void;
++type Logger = Pick<Console, 'log' | 'warn' | 'error'>;
++type Step = () => unknown;
++
++// ── graceful stop ───────────────────────────────────────────────
++
++/** A handle closed after the close steps: a function, or an object with close() (else stop(), end(), quit()). */
++export type StopHandle = (() => unknown) | { close(): unknown } | { stop(): unknown } | { end(): unknown } | { quit(): unknown };
++
++export interface GracefulStopOptions {
++ /** Log prefix, e.g. 'Network'. Default 'service'. */
++ name?: string;
++ /** The HTTP server to drain; leave it out for a worker without one. */
++ server?: Server | null;
++ /** Run first, in order (sync or async; a failure is logged and the stop goes on). */
++ stop?: Step[];
++ /** Run after the HTTP drain, in order. */
++ close?: Step[];
++ /** How long requests in flight may take before they are cut. Default 4000. */
++ drainMs?: number;
++ /** The whole stop; past it exit(deadlineExitCode). Default 5000. */
++ deadlineMs?: number;
++ /** Default 1 (Network, Community). The 5 s family and Media exit 0. */
++ deadlineExitCode?: number;
++ /** false: no SIGTERM/SIGINT handlers (tests; a caller that wires signals itself). Default true. */
++ signals?: boolean;
++ /** Default process.exit. Called once. */
++ exit?: (code: number) => unknown;
++ log?: Logger;
++ /** After the stop steps, before the server stops taking connections. */
++ beforeDrain?: (signal: string) => unknown;
++ /** Closed after the close steps; a rejection makes the exit code 1. */
++ handles?: StopHandle | StopHandle[];
++}
++
++export interface GracefulStop {
++ /** Starts the stop once; later calls return the same promise. Resolves with the exit code passed to exit. */
++ stop(signal?: string): Promise<number>;
++ /** True from the first stop() (or signal) on: a readiness check can answer 503. */
++ stopping(): boolean;
++}
++
++export declare function gracefulStop(options?: GracefulStopOptions): GracefulStop;
++/** `promise`, but no longer than `ms`; a rejection is swallowed (a best-effort step inside the deadline). */
++export declare function within<T>(ms: number, promise: Promise<T> | T): Promise<T | void>;
++export declare const DRAIN_MS: 4000;
++export declare const DEADLINE_MS: 5000;
++
++// ── errors ──────────────────────────────────────────────────────
++
++export interface ServiceErrorInstance extends Error {
++ status: number;
++ code: string;
++ detail?: string;
++ extra: Record<string, unknown> | null;
++}
++
++export interface ServiceErrorClass {
++ new (status: number, code: string, detail?: string, extra?: Record<string, unknown> | null): ServiceErrorInstance;
++ readonly prototype: ServiceErrorInstance;
++}
++
++export interface ServiceErrorOptions {
++ /** Log prefix ([name]). Default 'service'. */
++ name?: string;
++ log?: Pick<Console, 'error'>;
++ /** 'spread' (default: Blog/Trade, Reviews/Wiki) or 'details' ({ details: extra } below 500: Tips/VIP). */
++ extra?: 'spread' | 'details';
++ /** The code of an unexpected error. Default 'internal.error'. */
++ internalCode?: string;
++ /** The detail of an unexpected error. Default 'Internal error'. */
++ internalDetail?: string;
++ /** Map openvibe-publishing errors and plain TypeErrors (Blog/Trade asApiError). */
++ publishing?: boolean;
++ /** The class `publishing` builds. Default ServiceError. */
++ ServiceError?: ServiceErrorClass;
++ /** The service's own refusals → a ServiceError (or null). */
++ map?: (err: unknown) => ServiceErrorInstance | null | undefined;
++ /** run(): Cache-Control: private, no-store on the answer. */
++ noStore?: boolean;
++}
++
++type Handler = (req: Req, res: Res, next?: Next) => unknown;
++
++export declare const ServiceError: ServiceErrorClass;
++export declare function createServiceError(name?: string): ServiceErrorClass;
++export declare function asServiceError(err: unknown, options?: ServiceErrorOptions): ServiceErrorInstance | null;
++/** Any error → problem+json. Returns the body sent, or null when the headers were already out. */
++export declare function sendError(res: Res, req: Req, err: unknown, log?: Pick<Console, 'error'> | ServiceErrorOptions, options?: ServiceErrorOptions): Record<string, unknown> | null;
++export declare function run<T>(fn: (req: Req, res: Res) => T | Promise<T>, status?: number | ((out: T) => number), options?: ServiceErrorOptions | Pick<Console, 'error'>): (req: Req, res: Res) => Promise<void>;
++export declare function wrap(fn: Handler, options?: ServiceErrorOptions): (req: Req, res: Res, next?: Next) => Promise<void>;
++
++export interface JsonBodyOptions {
++ /** '512kb' (default), '1mb', or bytes. */
++ limit?: string | number;
++ /** Use this parser (express.json({ limit })) and map its errors. */
++ parser?: (req: Req, res: Res, next: Next) => void;
++}
++export declare function jsonBody(options?: JsonBodyOptions): (req: Req, res: Res, next: Next) => void;
++export declare function privateNoStore<R extends Res>(res: R): R;
++
++export interface JsonErrorsOptions extends ServiceErrorOptions {
++ /** Default ['/api/', '/internal/']. */
++ apiPrefix?: string | string[];
++ /** false: no 404 handler. */
++ notFound?: boolean;
++ notFoundText?: string;
++ errorText?: string;
++}
++export type JsonErrors = [(req: Req, res: Res) => unknown, (err: unknown, req: Req, res: Res, next: Next) => unknown] & {
++ notFound: (req: Req, res: Res) => unknown;
++ errorHandler: (err: unknown, req: Req, res: Res, next: Next) => unknown;
++};
++export declare function jsonErrors(options?: JsonErrorsOptions): JsonErrors;
++
++// ── re-exported from openvibe-shared and openvibe-contracts (loaded on first use) ──
++
++type Fn = (...args: any[]) => any;
++/** openvibe-shared/ready */
++export declare const createReadiness: Fn;
++export declare const skip: Fn;
++export declare const safeReason: Fn;
++/** openvibe-shared/metrics */
++export declare const createRegistry: Fn;
++export declare const instrument: Fn;
++export declare const metricsHandler: Fn;
++export declare const isLoopbackDirect: (req: Req) => boolean;
++export declare const releaseInfo: Fn;
++/** openvibe-shared/release */
++export declare const createRelease: Fn;
++
++export interface ProblemOptions {
++ title?: string;
++ detail?: string;
++ type?: string;
++ instance?: string;
++ ctx?: { requestId?: string; traceId?: string } | null;
++ errors?: unknown[];
++ extra?: Record<string, unknown>;
++}
++/** openvibe-contracts http.problem */
++export declare function problem(status: number, code: string, options?: ProblemOptions): Record<string, unknown>;
++/** openvibe-contracts http.sendProblem */
++export declare function sendProblem(res: Res, status: number, code: string, options?: ProblemOptions): Record<string, unknown>;