openvibers/openvibe.sdk Commits
0.16.0: openvibe-sdk/openre, the client for OpenRe.Stream, the platform's streaming engine. It covers streams (byExternalRef, create, get, update, delete, keys, rotateKey, destinations, addDestination), destinations (update, delete, test, start, stop, logs), sessions (list, get, playback cached 10 s, end, outputs), outputLogs, workers and manageUrl. Every call uses a service token for openvibe.openre, and { subject } acts for a person (X-OV-Subject). A missing stream or session is null; refusals are OpenVibeError. It is server-only (browser: null) and exported for CJS, ESM and types. Products stop carrying their own copy, starting with OpenVibe.Live.
Changes
@@@ -3,6 +3,16 @@All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minorrelease may change an API and says so here.++++## 0.16.0 (2026-09-28)++++- **`openvibe-sdk/openre`** (server): `createOpenReClient(client, { baseUrl?, publicUrl?, playbackTtlMs? })`, the client for OpenRe.Stream, the platform's streaming engine. Products stop carrying their own copy, starting with OpenVibe.Live.++ - `streams`: `list`, `byExternalRef`, `create`, `get` (null when missing), `update`, `delete`, `keys`, `rotateKey`, `destinations` and `addDestination`.++ - `destinations`: `update`, `delete`, `test`, `start`, `stop` and `logs`.++ - `sessions`: `list`, `get` (null when missing), `playback` (cached 10 s), `end` and `outputs`.++ - Also `outputLogs`, `workers` and `manageUrl`.++ - Every call uses a service token for `openvibe.openre`. `{ subject }` names the person a call acts for (`X-OV-Subject`).++## 0.15.0 (2026-09-28)**The event outbox and inbox on PostgreSQL** (ADR-004 on ADR-035), in `openvibe-sdk/events`. The better-sqlite3 versions stay for services that have not moved yet.@@@ -177,6 +177,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade || `openvibe-sdk/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()` || `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` |++| `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/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 |@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.15.0",++ "version": "0.16.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30","deployed": false,"deployment": "none: a package released as git tags; services and apps pin a tag",@@@ -15,7 +15,7 @@"events: publish, pull, app events, webhook verification (v2), outbox and inbox","realtime SSE","media files and objects v2",-- "community, search, chat, ai, jobs, tools, projects, modules, identity and vip clients",++ "community, search, chat, ai, jobs, tools, projects, modules, identity, openre and vip clients","per-actor limiter for services (openvibe-sdk/limits)","the OpenVibe Frame for any app","mock platform for tests (openvibe-sdk/testing)",@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.15.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.16.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). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */@@@ -40,7 +40,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.15.0';++const SDK_VERSION = '0.16.0';module.exports = {createClient,nnew file mode 100644@@@ -0,0 +1,7 @@++// Generated by scripts/esm.js from src/openre.js. Do not edit.++import cjs from '../src/openre.js';++++export const {++ createOpenReClient,++} = cjs;++export default cjs;@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.15.0",++ "version": "0.16.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.15.0",++ "version": "0.16.0","license": "MIT","devDependencies": {"@electric-sql/pglite": "^0.5.8",@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.15.0",++ "version": "0.16.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",@@@ -136,6 +136,12 @@"import": "./esm/frame.mjs","default": "./src/frame.js"},++ "./openre": {++ "types": "./types/openre.d.ts",++ "browser": null,++ "import": "./esm/openre.mjs",++ "default": "./src/openre.js"++ },"./vip": {"types": "./types/vip.d.ts","browser": null,@@@ -35,6 +35,7 @@ const ENTRIES = {'jobs.mjs': '../src/jobs.js','tools.mjs': '../src/tools.js','projects.mjs': '../src/projects.js',++ 'openre.mjs': '../src/openre.js','vip.mjs': '../src/vip.js','frame.mjs': '../src/frame.js','testing.mjs': '../src/testing/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.15.0';++const SDK_VERSION = '0.16.0';module.exports = {createClient,nnew file mode 100644@@@ -0,0 +1,91 @@++'use strict';++/**++ * openvibe-sdk/openre — OpenRe.Stream, the platform's streaming engine (ingest, sessions, restream outputs), for++ * every product that puts a stream on air (OpenVibe.Live first). One client instead of a copy in each product.++ * Server-side: it sends a Network service token for audience openvibe.openre.++ *++ * const tokens = createServiceTokenClient({ tokenUrl, clientId, clientSecret });++ * const openre = createOpenReClient(createClient({ tokenProvider: tokens, baseUrls: { openre: 'http://127.0.0.1:4500' } }));++ * const s = await openre.streams.byExternalRef('live:managed_stream:12'); // or null++ * const { stream } = await openre.streams.create({ title, external_refs }, { subject: 'usr_…' });++ * const { key } = await openre.streams.rotateKey(s.id, { subject, graceSeconds: 0 });++ * const pb = await openre.sessions.playback(sessionId); // cached 10 s++ *++ * Grants (on the caller's principal, audience openvibe.openre): openre.stream.read / .write, openre.key.rotate,++ * openre.output.read / .write, openre.session.read / .end. `subject` names the person a call acts for++ * (X-OV-Subject; a usr_ id); OpenRe checks that they own the stream. Errors are OpenVibeError (status, code);++ * a missing stream, destination or session is null from get(), not an error.++ * Shapes: Contracts openre.stream@1, openre.destination@1, openre.output@1, openre.session-read-result@1.++ */++const { isOpenVibeError } = require('./core/errors');++++const enc = encodeURIComponent;++const PLAYBACK_TTL_MS = 10_000;++++function createOpenReClient(client, { baseUrl, publicUrl = 'https://openre.stream', playbackTtlMs = PLAYBACK_TTL_MS, now = () => Date.now() } = {}) {++ if (!client || typeof client.json !== 'function') throw new TypeError('createOpenReClient: pass an openvibe-sdk/core client');++ const call = ({ subject, ...opts }) => client.json({++ service: 'openre', baseUrl, audience: 'openvibe.openre', ...opts,++ headers: { ...(opts.headers || {}), ...(subject ? { 'X-OV-Subject': String(subject) } : {}) },++ });++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });++ const pick = (key) => (r) => (r ? r[key] : null);++ const playbackCache = new Map();++++ const streams = {++ /** { streams, next_cursor? } — filter by external_ref ("<service>:<type>:<id>"). */++ list: ({ externalRef, limit, cursor, subject } = {}) => call({ path: '/api/v1/streams', query: { external_ref: externalRef, limit, cursor }, subject }),++ /** The stream a product object points at, or null. */++ byExternalRef: async (externalRef, opts = {}) => ((await streams.list({ ...opts, externalRef })).streams || [])[0] || null,++ /** → { stream, key? }. The key in a create answer is shown once. */++ create: (body, { subject, idempotencyKey } = {}) => call({ method: 'POST', path: '/api/v1/streams', json: body, subject, idempotencyKey }),++ get: (id, { subject } = {}) => orNull(call({ path: `/api/v1/streams/${enc(id)}`, subject })).then(pick('stream')),++ update: (id, patch, { subject } = {}) => call({ method: 'PATCH', path: `/api/v1/streams/${enc(id)}`, json: patch, subject }).then(pick('stream')),++ delete: (id, { subject } = {}) => call({ method: 'DELETE', path: `/api/v1/streams/${enc(id)}`, subject }),++ /** Key metadata (never the secret). */++ keys: (id, { subject } = {}) => call({ path: `/api/v1/streams/${enc(id)}/keys`, subject }),++ /** → openre.key-rotate-result@1 ({ key, ingest, … }); the old key keeps working for graceSeconds. */++ rotateKey: (id, { subject, graceSeconds = 0 } = {}) => call({ method: 'POST', path: `/api/v1/streams/${enc(id)}/keys/rotate`, json: { grace_seconds: graceSeconds }, subject }),++ destinations: (id, { subject } = {}) => call({ path: `/api/v1/streams/${enc(id)}/destinations`, subject }).then(pick('destinations')),++ addDestination: (id, body, { subject } = {}) => call({ method: 'POST', path: `/api/v1/streams/${enc(id)}/destinations`, json: body, subject }).then(pick('destination')),++ };++++ const destinations = {++ update: (id, patch, { subject } = {}) => call({ method: 'PATCH', path: `/api/v1/destinations/${enc(id)}`, json: patch, subject }).then(pick('destination')),++ delete: (id, { subject } = {}) => call({ method: 'DELETE', path: `/api/v1/destinations/${enc(id)}`, subject }),++ /** A short test push to the destination; → { ok, detail }. */++ test: (id, { subject } = {}) => call({ method: 'POST', path: `/api/v1/destinations/${enc(id)}/test`, subject, timeoutMs: 30_000 }),++ start: (id, { subject } = {}) => call({ method: 'POST', path: `/api/v1/destinations/${enc(id)}/start`, subject }),++ stop: (id, { subject } = {}) => call({ method: 'POST', path: `/api/v1/destinations/${enc(id)}/stop`, subject }),++ logs: (id, { subject, limit } = {}) => call({ path: `/api/v1/destinations/${enc(id)}/logs`, query: { limit }, subject }),++ };++++ const sessions = {++ list: ({ streamId, state, limit, cursor, subject } = {}) => call({ path: '/api/v1/sessions', query: { stream_id: streamId, state, limit, cursor }, subject }),++ get: (id, { subject } = {}) => orNull(call({ path: `/api/v1/sessions/${enc(id)}`, subject })).then(pick('session')),++ /** The playback descriptor (cached playbackTtlMs: players ask on every viewer connect). */++ async playback(id) {++ const hit = playbackCache.get(id);++ if (hit && now() - hit.at < playbackTtlMs) return hit.value;++ const value = await orNull(call({ path: `/api/v1/sessions/${enc(id)}/playback` })).then(pick('playback'));++ playbackCache.set(id, { at: now(), value });++ if (playbackCache.size > 500) for (const [k, v] of playbackCache) if (now() - v.at > 6 * playbackTtlMs) playbackCache.delete(k);++ return value;++ },++ end: (id, { subject, reason } = {}) => call({ method: 'POST', path: `/api/v1/sessions/${enc(id)}/end`, json: reason ? { reason } : {}, subject }),++ outputs: (id, { subject } = {}) => call({ path: `/api/v1/sessions/${enc(id)}/outputs`, subject }).then(pick('outputs')),++ };++++ return {++ streams,++ destinations,++ sessions,++ outputLogs: (id, { subject, limit } = {}) => call({ path: `/api/v1/outputs/${enc(id)}/logs`, query: { limit }, subject }),++ workers: () => call({ path: '/api/v1/workers' }),++ /** The standalone UI's page for a stream (for "manage on OpenRe.Stream" links). */++ manageUrl: (streamId) => `${String(publicUrl).replace(/\/+$/, '')}/streams/${enc(streamId || '')}`,++ clearCache: () => playbackCache.clear(),++ };++}++++module.exports = { createOpenReClient };@@@ -76,7 +76,7 @@ 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', './identity', './limits', './pubsub', './queue', './testing', './valkey', './vip']);++ assert.deepEqual(blocked.sort(), ['./cache', './db', './events', './identity', './limits', './openre', './pubsub', './queue', './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`);nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++/** OpenRe: service-token calls with the acting subject, external_ref lookup, key rotation, null for a missing++ * stream or session, playback cached 10 s, destinations and sessions paths, and errors kept as OpenVibeError. */++const assert = require('node:assert/strict');++const { stubServer, send, run } = require('./helpers');++const { createClient, isOpenVibeError } = require('../src/core');++const { createOpenReClient } = require('../src/openre');++++const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++++async function openre() {++ return stubServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ const p = u.pathname;++ if (p === '/api/v1/streams' && req.method === 'GET') return send(res, 200, { streams: u.searchParams.get('external_ref') === 'live:managed_stream:12' ? [{ id: 'str_1', title: 'Slot' }] : [] });++ if (p === '/api/v1/streams' && req.method === 'POST') return send(res, 201, { stream: { id: 'str_2', title: 'New' }, key: { secret: 'shown-once' } });++ if (p === '/api/v1/streams/str_404') return send(res, 404, { type: 'about:blank', code: 'openre.not_found', status: 404 });++ if (p === '/api/v1/streams/str_1' && req.method === 'GET') return send(res, 200, { stream: { id: 'str_1' } });++ if (p === '/api/v1/streams/str_1/keys/rotate') return send(res, 200, { key: { secret: 'k2' }, ingest: { rtmp: 'rtmp://ingest/app' } });++ if (p === '/api/v1/streams/str_1/destinations' && req.method === 'POST') return send(res, 201, { destination: { id: 'dst_1' } });++ if (p === '/api/v1/destinations/dst_1/start') return send(res, 202, { ok: true });++ if (p === '/api/v1/sessions/ses_1/playback') return send(res, 200, { playback: { flv: 'http://127.0.0.1:4501/live/x.flv' } });++ if (p === '/api/v1/sessions/ses_404') return send(res, 404, { code: 'openre.not_found', status: 404 });++ if (p === '/api/v1/sessions/ses_1/end') return send(res, 409, { type: 'about:blank', code: 'openre.session_ended', status: 409, detail: 'already ended' });++ return send(res, 404, {});++ });++}++++run([++ ['streams: external_ref lookup, create with the acting subject, get null on 404, rotate', async () => {++ const srv = await openre();++ const tokens = { getToken: async ({ audience }) => `tok-for-${audience}` };++ const o = createOpenReClient(createClient({ tokenProvider: tokens, baseUrls: { openre: srv.url }, retries: 0 }));++ assert.deepEqual(await o.streams.byExternalRef('live:managed_stream:12'), { id: 'str_1', title: 'Slot' });++ assert.equal(await o.streams.byExternalRef('live:managed_stream:99'), null);++ const first = srv.requests[0];++ assert.equal(first.headers.authorization, 'Bearer tok-for-openvibe.openre', 'a service token for openvibe.openre');++ assert.equal(new URL(first.url, 'http://x').searchParams.get('external_ref'), 'live:managed_stream:12');++ const made = await o.streams.create({ title: 'New' }, { subject: USR });++ assert.equal(made.stream.id, 'str_2');++ const post = srv.requests.at(-1);++ assert.equal(post.headers['x-ov-subject'], USR);++ assert.deepEqual(JSON.parse(post.body), { title: 'New' });++ assert.equal(await o.streams.get('str_404'), null);++ assert.deepEqual(await o.streams.get('str_1'), { id: 'str_1' });++ const rot = await o.streams.rotateKey('str_1', { subject: USR, graceSeconds: 30 });++ assert.equal(rot.ingest.rtmp, 'rtmp://ingest/app');++ assert.deepEqual(JSON.parse(srv.requests.at(-1).body), { grace_seconds: 30 });++ assert.equal((await o.streams.addDestination('str_1', { kind: 'rtmp', url: 'rtmp://a/b' }, { subject: USR })).id, 'dst_1');++ assert.deepEqual(await o.destinations.start('dst_1', { subject: USR }), { ok: true });++ assert.equal(o.manageUrl('str_1'), 'https://openre.stream/streams/str_1');++ await srv.close();++ }],++ ['sessions: playback is cached, a missing session is null, a refusal is an OpenVibeError', async () => {++ const srv = await openre();++ let t = 1000;++ const o = createOpenReClient(createClient({ token: 'svc', baseUrls: { openre: srv.url }, retries: 0 }), { now: () => t });++ assert.deepEqual(await o.sessions.playback('ses_1'), { flv: 'http://127.0.0.1:4501/live/x.flv' });++ await o.sessions.playback('ses_1');++ assert.equal(srv.requests.length, 1, 'cached');++ t += 10_001;++ await o.sessions.playback('ses_1');++ assert.equal(srv.requests.length, 2, 'refreshed after 10 s');++ assert.equal(await o.sessions.get('ses_404'), null);++ await assert.rejects(o.sessions.end('ses_1', { reason: 'admin' }), (err) => isOpenVibeError(err) && err.status === 409 && err.code === 'openre.session_ended');++ await srv.close();++ }],++ ['needs a core client', () => {++ assert.throws(() => createOpenReClient(null), /openvibe-sdk\/core client/);++ }],++]);@@@ -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, 26);++ assert.equal(subpaths.length, 27);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 });@@@ -14,3 +14,4 @@ export * as jobs from './jobs';export * as tools from './tools';export * as projects from './projects';export * as vip from './vip';++export * as openre from './openre';nnew file mode 100644@@@ -0,0 +1,48 @@++import type { OpenVibeClient } from './core';++++/** Contracts openre.stream@1 (abridged; see openvibe-contracts for the full shape). */++export interface OpenReStream {++ id: string; title: string; owner_subject?: string; state?: string;++ recording_mode?: string; recording_visibility?: string; mirror_to_live?: boolean;++ external_refs?: Array<{ service: string; type: string; id: string; label?: string | null }>;++ ingest?: Record<string, unknown>;++ [field: string]: unknown;++}++export interface OpenReSession { id: string; stream_id: string; state: string; started_at?: string; ended_at?: string | null; [field: string]: unknown }++export interface OpenReDestination { id: string; stream_id: string; kind?: string; enabled?: boolean; [field: string]: unknown }++export interface Acting { subject?: string }++++export interface OpenReClient {++ streams: {++ list(opts?: Acting & { externalRef?: string; limit?: number; cursor?: string }): Promise<{ streams: OpenReStream[]; next_cursor?: string | null }>;++ byExternalRef(externalRef: string, opts?: Acting): Promise<OpenReStream | null>;++ create(body: Record<string, unknown>, opts?: Acting & { idempotencyKey?: string }): Promise<{ stream: OpenReStream; key?: unknown }>;++ get(id: string, opts?: Acting): Promise<OpenReStream | null>;++ update(id: string, patch: Record<string, unknown>, opts?: Acting): Promise<OpenReStream>;++ delete(id: string, opts?: Acting): Promise<unknown>;++ keys(id: string, opts?: Acting): Promise<unknown>;++ rotateKey(id: string, opts?: Acting & { graceSeconds?: number }): Promise<Record<string, unknown>>;++ destinations(id: string, opts?: Acting): Promise<OpenReDestination[]>;++ addDestination(id: string, body: Record<string, unknown>, opts?: Acting): Promise<OpenReDestination>;++ };++ destinations: {++ update(id: string, patch: Record<string, unknown>, opts?: Acting): Promise<OpenReDestination>;++ delete(id: string, opts?: Acting): Promise<unknown>;++ test(id: string, opts?: Acting): Promise<{ ok: boolean; detail?: string; [k: string]: unknown }>;++ start(id: string, opts?: Acting): Promise<unknown>;++ stop(id: string, opts?: Acting): Promise<unknown>;++ logs(id: string, opts?: Acting & { limit?: number }): Promise<unknown>;++ };++ sessions: {++ list(opts?: Acting & { streamId?: string; state?: string; limit?: number; cursor?: string }): Promise<{ sessions: OpenReSession[]; next_cursor?: string | null }>;++ get(id: string, opts?: Acting): Promise<OpenReSession | null>;++ playback(id: string): Promise<Record<string, unknown> | null>;++ end(id: string, opts?: Acting & { reason?: string }): Promise<unknown>;++ outputs(id: string, opts?: Acting): Promise<unknown[]>;++ };++ outputLogs(id: string, opts?: Acting & { limit?: number }): Promise<unknown>;++ workers(): Promise<unknown>;++ manageUrl(streamId: string): string;++ clearCache(): void;++}++export function createOpenReClient(client: OpenVibeClient, opts?: { baseUrl?: string; publicUrl?: string; playbackTtlMs?: number; now?: () => number }): OpenReClient;
@@ -3,6 +3,16 @@
@@ -3,6 +3,16 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
release may change an API and says so here.
+
+## 0.16.0 (2026-09-28)
+
+- **`openvibe-sdk/openre`** (server): `createOpenReClient(client, { baseUrl?, publicUrl?, playbackTtlMs? })`, the client for OpenRe.Stream, the platform's streaming engine. Products stop carrying their own copy, starting with OpenVibe.Live.
+ - `streams`: `list`, `byExternalRef`, `create`, `get` (null when missing), `update`, `delete`, `keys`, `rotateKey`, `destinations` and `addDestination`.
+ - `destinations`: `update`, `delete`, `test`, `start`, `stop` and `logs`.
+ - `sessions`: `list`, `get` (null when missing), `playback` (cached 10 s), `end` and `outputs`.
+ - Also `outputLogs`, `workers` and `manageUrl`.
+ - Every call uses a service token for `openvibe.openre`. `{ subject }` names the person a call acts for (`X-OV-Subject`).
+
## 0.15.0 (2026-09-28)
## 0.15.0 (2026-09-28)
**The event outbox and inbox on PostgreSQL** (ADR-004 on ADR-035), in `openvibe-sdk/events`. The better-sqlite3 versions stay for services that have not moved yet.
**The event outbox and inbox on PostgreSQL** (ADR-004 on ADR-035), in `openvibe-sdk/events`. The better-sqlite3 versions stay for services that have not moved yet.
@@ -177,6 +177,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -177,6 +177,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade |
| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade |
| `openvibe-sdk/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()` |
| `openvibe-sdk/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()` |
| `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` |
| `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` |
+| `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/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/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/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 |
| `openvibe-sdk/browser/openvibe-sdk.mjs` | browser | one self-contained ES module: core + auth (browser), registry, modules, realtime, media, community, jobs, tools, projects |
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.15.0",
+ "version": "0.16.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"events: publish, pull, app events, webhook verification (v2), outbox and inbox",
"events: publish, pull, app events, webhook verification (v2), outbox and inbox",
"realtime SSE",
"realtime SSE",
"media files and objects v2",
"media files and objects v2",
- "community, search, chat, ai, jobs, tools, projects, modules, identity and vip clients",
+ "community, search, chat, ai, jobs, tools, projects, modules, identity, openre and vip clients",
"per-actor limiter for services (openvibe-sdk/limits)",
"per-actor limiter for services (openvibe-sdk/limits)",
"the OpenVibe Frame for any app",
"the OpenVibe Frame for any app",
"mock platform for tests (openvibe-sdk/testing)",
"mock platform for tests (openvibe-sdk/testing)",
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.15.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.16.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// 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). No server-only code: no service tokens, JWT verification, events, identity.
// (auth, registry, modules, realtime, media, community, search, chat, ai, jobs, tools, projects). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -40,7 +40,7 @@ const { paginate, offsetPager } = __load(7);
@@ -40,7 +40,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.15.0';
+const SDK_VERSION = '0.16.0';
module.exports = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+// Generated by scripts/esm.js from src/openre.js. Do not edit.
+import cjs from '../src/openre.js';
+
+export const {
+ createOpenReClient,
+} = cjs;
+export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.15.0",
+ "version": "0.16.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.15.0",
+ "version": "0.16.0",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.15.0",
+ "version": "0.16.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.",
"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",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -136,6 +136,12 @@
@@ -136,6 +136,12 @@
"import": "./esm/frame.mjs",
"import": "./esm/frame.mjs",
"default": "./src/frame.js"
"default": "./src/frame.js"
},
},
+ "./openre": {
+ "types": "./types/openre.d.ts",
+ "browser": null,
+ "import": "./esm/openre.mjs",
+ "default": "./src/openre.js"
+ },
"./vip": {
"./vip": {
"types": "./types/vip.d.ts",
"types": "./types/vip.d.ts",
"browser": null,
"browser": null,
@@ -35,6 +35,7 @@ const ENTRIES = {
@@ -35,6 +35,7 @@ const ENTRIES = {
'jobs.mjs': '../src/jobs.js',
'jobs.mjs': '../src/jobs.js',
'tools.mjs': '../src/tools.js',
'tools.mjs': '../src/tools.js',
'projects.mjs': '../src/projects.js',
'projects.mjs': '../src/projects.js',
+ 'openre.mjs': '../src/openre.js',
'vip.mjs': '../src/vip.js',
'vip.mjs': '../src/vip.js',
'frame.mjs': '../src/frame.js',
'frame.mjs': '../src/frame.js',
'testing.mjs': '../src/testing/index.js',
'testing.mjs': '../src/testing/index.js',
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.15.0';
+const SDK_VERSION = '0.16.0';
module.exports = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+'use strict';
+/**
+ * openvibe-sdk/openre — OpenRe.Stream, the platform's streaming engine (ingest, sessions, restream outputs), for
+ * every product that puts a stream on air (OpenVibe.Live first). One client instead of a copy in each product.
+ * Server-side: it sends a Network service token for audience openvibe.openre.
+ *
+ * const tokens = createServiceTokenClient({ tokenUrl, clientId, clientSecret });
+ * const openre = createOpenReClient(createClient({ tokenProvider: tokens, baseUrls: { openre: 'http://127.0.0.1:4500' } }));
+ * const s = await openre.streams.byExternalRef('live:managed_stream:12'); // or null
+ * const { stream } = await openre.streams.create({ title, external_refs }, { subject: 'usr_…' });
+ * const { key } = await openre.streams.rotateKey(s.id, { subject, graceSeconds: 0 });
+ * const pb = await openre.sessions.playback(sessionId); // cached 10 s
+ *
+ * Grants (on the caller's principal, audience openvibe.openre): openre.stream.read / .write, openre.key.rotate,
+ * openre.output.read / .write, openre.session.read / .end. `subject` names the person a call acts for
+ * (X-OV-Subject; a usr_ id); OpenRe checks that they own the stream. Errors are OpenVibeError (status, code);
+ * a missing stream, destination or session is null from get(), not an error.
+ * Shapes: Contracts openre.stream@1, openre.destination@1, openre.output@1, openre.session-read-result@1.
+ */
+const { isOpenVibeError } = require('./core/errors');
+
+const enc = encodeURIComponent;
+const PLAYBACK_TTL_MS = 10_000;
+
+function createOpenReClient(client, { baseUrl, publicUrl = 'https://openre.stream', playbackTtlMs = PLAYBACK_TTL_MS, now = () => Date.now() } = {}) {
+ if (!client || typeof client.json !== 'function') throw new TypeError('createOpenReClient: pass an openvibe-sdk/core client');
+ const call = ({ subject, ...opts }) => client.json({
+ service: 'openre', baseUrl, audience: 'openvibe.openre', ...opts,
+ headers: { ...(opts.headers || {}), ...(subject ? { 'X-OV-Subject': String(subject) } : {}) },
+ });
+ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
+ const pick = (key) => (r) => (r ? r[key] : null);
+ const playbackCache = new Map();
+
+ const streams = {
+ /** { streams, next_cursor? } — filter by external_ref ("<service>:<type>:<id>"). */
+ list: ({ externalRef, limit, cursor, subject } = {}) => call({ path: '/api/v1/streams', query: { external_ref: externalRef, limit, cursor }, subject }),
+ /** The stream a product object points at, or null. */
+ byExternalRef: async (externalRef, opts = {}) => ((await streams.list({ ...opts, externalRef })).streams || [])[0] || null,
+ /** → { stream, key? }. The key in a create answer is shown once. */
+ create: (body, { subject, idempotencyKey } = {}) => call({ method: 'POST', path: '/api/v1/streams', json: body, subject, idempotencyKey }),
+ get: (id, { subject } = {}) => orNull(call({ path: `/api/v1/streams/${enc(id)}`, subject })).then(pick('stream')),
+ update: (id, patch, { subject } = {}) => call({ method: 'PATCH', path: `/api/v1/streams/${enc(id)}`, json: patch, subject }).then(pick('stream')),
+ delete: (id, { subject } = {}) => call({ method: 'DELETE', path: `/api/v1/streams/${enc(id)}`, subject }),
+ /** Key metadata (never the secret). */
+ keys: (id, { subject } = {}) => call({ path: `/api/v1/streams/${enc(id)}/keys`, subject }),
+ /** → openre.key-rotate-result@1 ({ key, ingest, … }); the old key keeps working for graceSeconds. */
+ rotateKey: (id, { subject, graceSeconds = 0 } = {}) => call({ method: 'POST', path: `/api/v1/streams/${enc(id)}/keys/rotate`, json: { grace_seconds: graceSeconds }, subject }),
+ destinations: (id, { subject } = {}) => call({ path: `/api/v1/streams/${enc(id)}/destinations`, subject }).then(pick('destinations')),
+ addDestination: (id, body, { subject } = {}) => call({ method: 'POST', path: `/api/v1/streams/${enc(id)}/destinations`, json: body, subject }).then(pick('destination')),
+ };
+
+ const destinations = {
+ update: (id, patch, { subject } = {}) => call({ method: 'PATCH', path: `/api/v1/destinations/${enc(id)}`, json: patch, subject }).then(pick('destination')),
+ delete: (id, { subject } = {}) => call({ method: 'DELETE', path: `/api/v1/destinations/${enc(id)}`, subject }),
+ /** A short test push to the destination; → { ok, detail }. */
+ test: (id, { subject } = {}) => call({ method: 'POST', path: `/api/v1/destinations/${enc(id)}/test`, subject, timeoutMs: 30_000 }),
+ start: (id, { subject } = {}) => call({ method: 'POST', path: `/api/v1/destinations/${enc(id)}/start`, subject }),
+ stop: (id, { subject } = {}) => call({ method: 'POST', path: `/api/v1/destinations/${enc(id)}/stop`, subject }),
+ logs: (id, { subject, limit } = {}) => call({ path: `/api/v1/destinations/${enc(id)}/logs`, query: { limit }, subject }),
+ };
+
+ const sessions = {
+ list: ({ streamId, state, limit, cursor, subject } = {}) => call({ path: '/api/v1/sessions', query: { stream_id: streamId, state, limit, cursor }, subject }),
+ get: (id, { subject } = {}) => orNull(call({ path: `/api/v1/sessions/${enc(id)}`, subject })).then(pick('session')),
+ /** The playback descriptor (cached playbackTtlMs: players ask on every viewer connect). */
+ async playback(id) {
+ const hit = playbackCache.get(id);
+ if (hit && now() - hit.at < playbackTtlMs) return hit.value;
+ const value = await orNull(call({ path: `/api/v1/sessions/${enc(id)}/playback` })).then(pick('playback'));
+ playbackCache.set(id, { at: now(), value });
+ if (playbackCache.size > 500) for (const [k, v] of playbackCache) if (now() - v.at > 6 * playbackTtlMs) playbackCache.delete(k);
+ return value;
+ },
+ end: (id, { subject, reason } = {}) => call({ method: 'POST', path: `/api/v1/sessions/${enc(id)}/end`, json: reason ? { reason } : {}, subject }),
+ outputs: (id, { subject } = {}) => call({ path: `/api/v1/sessions/${enc(id)}/outputs`, subject }).then(pick('outputs')),
+ };
+
+ return {
+ streams,
+ destinations,
+ sessions,
+ outputLogs: (id, { subject, limit } = {}) => call({ path: `/api/v1/outputs/${enc(id)}/logs`, query: { limit }, subject }),
+ workers: () => call({ path: '/api/v1/workers' }),
+ /** The standalone UI's page for a stream (for "manage on OpenRe.Stream" links). */
+ manageUrl: (streamId) => `${String(publicUrl).replace(/\/+$/, '')}/streams/${enc(streamId || '')}`,
+ clearCache: () => playbackCache.clear(),
+ };
+}
+
+module.exports = { createOpenReClient };
@@ -76,7 +76,7 @@ run([
@@ -76,7 +76,7 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
}
assert.deepEqual(problems, []);
assert.deepEqual(problems, []);
- assert.deepEqual(blocked.sort(), ['./cache', './db', './events', './identity', './limits', './pubsub', './queue', './testing', './valkey', './vip']);
+ assert.deepEqual(blocked.sort(), ['./cache', './db', './events', './identity', './limits', './openre', './pubsub', './queue', './testing', './valkey', './vip']);
const rel = [...scanned].map((f) => path.relative(ROOT, f));
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']) {
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`);
assert.ok(rel.includes(must), `${must} is scanned`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+/** OpenRe: service-token calls with the acting subject, external_ref lookup, key rotation, null for a missing
+ * stream or session, playback cached 10 s, destinations and sessions paths, and errors kept as OpenVibeError. */
+const assert = require('node:assert/strict');
+const { stubServer, send, run } = require('./helpers');
+const { createClient, isOpenVibeError } = require('../src/core');
+const { createOpenReClient } = require('../src/openre');
+
+const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+
+async function openre() {
+ return stubServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ const p = u.pathname;
+ if (p === '/api/v1/streams' && req.method === 'GET') return send(res, 200, { streams: u.searchParams.get('external_ref') === 'live:managed_stream:12' ? [{ id: 'str_1', title: 'Slot' }] : [] });
+ if (p === '/api/v1/streams' && req.method === 'POST') return send(res, 201, { stream: { id: 'str_2', title: 'New' }, key: { secret: 'shown-once' } });
+ if (p === '/api/v1/streams/str_404') return send(res, 404, { type: 'about:blank', code: 'openre.not_found', status: 404 });
+ if (p === '/api/v1/streams/str_1' && req.method === 'GET') return send(res, 200, { stream: { id: 'str_1' } });
+ if (p === '/api/v1/streams/str_1/keys/rotate') return send(res, 200, { key: { secret: 'k2' }, ingest: { rtmp: 'rtmp://ingest/app' } });
+ if (p === '/api/v1/streams/str_1/destinations' && req.method === 'POST') return send(res, 201, { destination: { id: 'dst_1' } });
+ if (p === '/api/v1/destinations/dst_1/start') return send(res, 202, { ok: true });
+ if (p === '/api/v1/sessions/ses_1/playback') return send(res, 200, { playback: { flv: 'http://127.0.0.1:4501/live/x.flv' } });
+ if (p === '/api/v1/sessions/ses_404') return send(res, 404, { code: 'openre.not_found', status: 404 });
+ if (p === '/api/v1/sessions/ses_1/end') return send(res, 409, { type: 'about:blank', code: 'openre.session_ended', status: 409, detail: 'already ended' });
+ return send(res, 404, {});
+ });
+}
+
+run([
+ ['streams: external_ref lookup, create with the acting subject, get null on 404, rotate', async () => {
+ const srv = await openre();
+ const tokens = { getToken: async ({ audience }) => `tok-for-${audience}` };
+ const o = createOpenReClient(createClient({ tokenProvider: tokens, baseUrls: { openre: srv.url }, retries: 0 }));
+ assert.deepEqual(await o.streams.byExternalRef('live:managed_stream:12'), { id: 'str_1', title: 'Slot' });
+ assert.equal(await o.streams.byExternalRef('live:managed_stream:99'), null);
+ const first = srv.requests[0];
+ assert.equal(first.headers.authorization, 'Bearer tok-for-openvibe.openre', 'a service token for openvibe.openre');
+ assert.equal(new URL(first.url, 'http://x').searchParams.get('external_ref'), 'live:managed_stream:12');
+ const made = await o.streams.create({ title: 'New' }, { subject: USR });
+ assert.equal(made.stream.id, 'str_2');
+ const post = srv.requests.at(-1);
+ assert.equal(post.headers['x-ov-subject'], USR);
+ assert.deepEqual(JSON.parse(post.body), { title: 'New' });
+ assert.equal(await o.streams.get('str_404'), null);
+ assert.deepEqual(await o.streams.get('str_1'), { id: 'str_1' });
+ const rot = await o.streams.rotateKey('str_1', { subject: USR, graceSeconds: 30 });
+ assert.equal(rot.ingest.rtmp, 'rtmp://ingest/app');
+ assert.deepEqual(JSON.parse(srv.requests.at(-1).body), { grace_seconds: 30 });
+ assert.equal((await o.streams.addDestination('str_1', { kind: 'rtmp', url: 'rtmp://a/b' }, { subject: USR })).id, 'dst_1');
+ assert.deepEqual(await o.destinations.start('dst_1', { subject: USR }), { ok: true });
+ assert.equal(o.manageUrl('str_1'), 'https://openre.stream/streams/str_1');
+ await srv.close();
+ }],
+ ['sessions: playback is cached, a missing session is null, a refusal is an OpenVibeError', async () => {
+ const srv = await openre();
+ let t = 1000;
+ const o = createOpenReClient(createClient({ token: 'svc', baseUrls: { openre: srv.url }, retries: 0 }), { now: () => t });
+ assert.deepEqual(await o.sessions.playback('ses_1'), { flv: 'http://127.0.0.1:4501/live/x.flv' });
+ await o.sessions.playback('ses_1');
+ assert.equal(srv.requests.length, 1, 'cached');
+ t += 10_001;
+ await o.sessions.playback('ses_1');
+ assert.equal(srv.requests.length, 2, 'refreshed after 10 s');
+ assert.equal(await o.sessions.get('ses_404'), null);
+ await assert.rejects(o.sessions.end('ses_1', { reason: 'admin' }), (err) => isOpenVibeError(err) && err.status === 409 && err.code === 'openre.session_ended');
+ await srv.close();
+ }],
+ ['needs a core client', () => {
+ assert.throws(() => createOpenReClient(null), /openvibe-sdk\/core client/);
+ }],
+]);
@@ -66,7 +66,7 @@ run([
@@ -66,7 +66,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
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.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
- assert.equal(subpaths.length, 26);
+ assert.equal(subpaths.length, 27);
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
@@ -14,3 +14,4 @@ export * as jobs from './jobs';
@@ -14,3 +14,4 @@ export * as jobs from './jobs';
export * as tools from './tools';
export * as tools from './tools';
export * as projects from './projects';
export * as projects from './projects';
export * as vip from './vip';
export * as vip from './vip';
+export * as openre from './openre';
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+import type { OpenVibeClient } from './core';
+
+/** Contracts openre.stream@1 (abridged; see openvibe-contracts for the full shape). */
+export interface OpenReStream {
+ id: string; title: string; owner_subject?: string; state?: string;
+ recording_mode?: string; recording_visibility?: string; mirror_to_live?: boolean;
+ external_refs?: Array<{ service: string; type: string; id: string; label?: string | null }>;
+ ingest?: Record<string, unknown>;
+ [field: string]: unknown;
+}
+export interface OpenReSession { id: string; stream_id: string; state: string; started_at?: string; ended_at?: string | null; [field: string]: unknown }
+export interface OpenReDestination { id: string; stream_id: string; kind?: string; enabled?: boolean; [field: string]: unknown }
+export interface Acting { subject?: string }
+
+export interface OpenReClient {
+ streams: {
+ list(opts?: Acting & { externalRef?: string; limit?: number; cursor?: string }): Promise<{ streams: OpenReStream[]; next_cursor?: string | null }>;
+ byExternalRef(externalRef: string, opts?: Acting): Promise<OpenReStream | null>;
+ create(body: Record<string, unknown>, opts?: Acting & { idempotencyKey?: string }): Promise<{ stream: OpenReStream; key?: unknown }>;
+ get(id: string, opts?: Acting): Promise<OpenReStream | null>;
+ update(id: string, patch: Record<string, unknown>, opts?: Acting): Promise<OpenReStream>;
+ delete(id: string, opts?: Acting): Promise<unknown>;
+ keys(id: string, opts?: Acting): Promise<unknown>;
+ rotateKey(id: string, opts?: Acting & { graceSeconds?: number }): Promise<Record<string, unknown>>;
+ destinations(id: string, opts?: Acting): Promise<OpenReDestination[]>;
+ addDestination(id: string, body: Record<string, unknown>, opts?: Acting): Promise<OpenReDestination>;
+ };
+ destinations: {
+ update(id: string, patch: Record<string, unknown>, opts?: Acting): Promise<OpenReDestination>;
+ delete(id: string, opts?: Acting): Promise<unknown>;
+ test(id: string, opts?: Acting): Promise<{ ok: boolean; detail?: string; [k: string]: unknown }>;
+ start(id: string, opts?: Acting): Promise<unknown>;
+ stop(id: string, opts?: Acting): Promise<unknown>;
+ logs(id: string, opts?: Acting & { limit?: number }): Promise<unknown>;
+ };
+ sessions: {
+ list(opts?: Acting & { streamId?: string; state?: string; limit?: number; cursor?: string }): Promise<{ sessions: OpenReSession[]; next_cursor?: string | null }>;
+ get(id: string, opts?: Acting): Promise<OpenReSession | null>;
+ playback(id: string): Promise<Record<string, unknown> | null>;
+ end(id: string, opts?: Acting & { reason?: string }): Promise<unknown>;
+ outputs(id: string, opts?: Acting): Promise<unknown[]>;
+ };
+ outputLogs(id: string, opts?: Acting & { limit?: number }): Promise<unknown>;
+ workers(): Promise<unknown>;
+ manageUrl(streamId: string): string;
+ clearCache(): void;
+}
+export function createOpenReClient(client: OpenVibeClient, opts?: { baseUrl?: string; publicUrl?: string; playbackTtlMs?: number; now?: () => number }): OpenReClient;