openvibers/openvibe.sdk Commits
0.17.0: openvibe-sdk/geo, the nearest OpenVibe node (roadmap WS-X1 task 5, ADR-034 §12). It works in the browser and on the server. nodes({ role, region }) reads Network's public node registry (GET /api/v1/nodes, network.node-list-result@1) without a token. measure(nodes) times each beacon several times and keeps the best sample, because the first also pays for DNS and TLS; results come fastest first. nearest() returns the fastest measured node that is up or degraded; when nothing can be measured it falls back to preferRegion and then to the first healthy node, and says measured: false. Game-server lists, ping tools and ingest pick use it. geo is in the root export and the browser bundle, and openre is in the root export.
Changes
@@@ -4,6 +4,15 @@ All notable changes to `openvibe-sdk`. The package follows semver; while it is `release may change an API and says so here.++++## 0.17.0 (2026-09-28)++++- **`openvibe-sdk/geo`** (browser and server): `createGeoClient(client, { samples?, timeoutMs? })` finds the closest OpenVibe node (roadmap WS-X1 task 5).++ - `nodes({ role?, region? })` reads Network's public node registry, `GET /api/v1/nodes` (`network.node-list-result@1`), without sending a token.++ - `measure(nodes)` times each node's beacon several times (the best sample counts, since the first also pays for DNS and TLS) and returns the nodes fastest first.++ - `nearest({ role?, region?, preferRegion? })` returns the fastest measured node that is up or degraded. When nothing can be measured, it falls back to `preferRegion`, then to the first healthy node, and returns `measured: false`.++- The root export and the browser bundle include `geo`. The root export also includes `openre`.++## 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.@@@ -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/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/testing` | Node | `createMockPlatform()`: fake Network (incl. developer apps and projects), Events, Media, Tools jobs and the Tools platform API on an in-process `fetch` |@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.16.0",++ "version": "0.17.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, openre and vip clients",++ "community, search, chat, ai, jobs, tools, projects, modules, identity, openre, geo and vip clients","per-actor limiter for services (openvibe-sdk/limits)","the OpenVibe Frame for any app","mock platform for tests (openvibe-sdk/testing)",@@@ -19,4 +19,5 @@ module.exports = {jobs: require('./src/jobs'),tools: require('./src/tools'),projects: require('./src/projects'),++ geo: require('./src/geo'),};@@@ -1,6 +1,6 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.16.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.17.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.++// (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 */const __defs = [// ── 0: browser.js ──────────────────────────────────────────────────@@@ -26,6 +26,7 @@ module.exports = {jobs: __load(18),tools: __load(20),projects: __load(21),++ geo: __load(22),};},// ── 1: src/core/index.js ───────────────────────────────────────────@@@ -40,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.16.0';++const SDK_VERSION = '0.17.0';module.exports = {createClient,@@@ -2605,6 +2606,70 @@ function createProjectsClient(client, { baseUrl } = {}) {module.exports = { createProjectsClient };},++// ── 22: src/geo.js ──────────────────────────────────────────────────++function (module, exports) {++'use strict';++/**++ * openvibe-sdk/geo — which OpenVibe node is closest (roadmap WS-X1 task 5, ADR-034 §12). Browser-safe.++ *++ * const geo = createGeoClient(createClient());++ * const { node, rtt_ms } = await geo.nearest({ role: 'edge-probe' }); // measured from here++ * const all = await geo.measure(await geo.nodes({ role: 'ingest' })); // [{ node, rtt_ms, ok }] fastest first++ *++ * The registry is Network's public GET /api/v1/nodes (network.node-list-result@1). Distance is measured, not++ * guessed: each node's beacon is fetched `samples` times and the best round trip counts (the first fetch also++ * pays for DNS and TLS). Only up or degraded nodes are candidates. When nothing can be measured (offline, every++ * beacon blocked) nearest() falls back to `preferRegion`, then to the first healthy node, and says so++ * (`measured: false`). Location is region-level only: the registry holds no addresses.++ */++const nowMs = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now());++++function createGeoClient(client, { baseUrl, fetch: fetchImpl = null, samples = 3, timeoutMs = 3000, now = nowMs } = {}) {++ if (!client || typeof client.json !== 'function') throw new TypeError('createGeoClient: pass an openvibe-sdk/core client');++ const doFetch = fetchImpl || (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null);++++ /** The registry's nodes, optionally for one role or region. */++ async function nodes({ role, region } = {}) {++ const r = await client.json({ service: 'network', baseUrl, path: '/api/v1/nodes', query: { role, region }, auth: false });++ return (r && r.nodes) || [];++ }++++ async function rtt(node) {++ let best = null;++ for (let i = 0; i < samples; i++) {++ const t0 = now();++ try {++ const res = await doFetch(`${node.beacon}${node.beacon.includes('?') ? '&' : '?'}t=${Date.now()}-${i}`, { cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) });++ if (!res.ok && res.status !== 204) continue;++ const ms = now() - t0;++ if (best == null || ms < best) best = ms;++ } catch { /* this sample failed; others may not */ }++ }++ return best == null ? null : Math.round(best * 10) / 10;++ }++++ /** Round trips to each node's beacon, measured in parallel: [{ node, rtt_ms, ok }], fastest first. */++ async function measure(list) {++ if (!doFetch) throw new TypeError('createGeoClient: no fetch available to measure with');++ const out = await Promise.all(list.map(async (node) => { const ms = await rtt(node); return { node, rtt_ms: ms, ok: ms != null }; }));++ return out.sort((a, b) => (a.ok === b.ok ? (a.rtt_ms ?? Infinity) - (b.rtt_ms ?? Infinity) : a.ok ? -1 : 1));++ }++++ /** The closest healthy node for a role → { node, rtt_ms, measured } or null when the registry has none. */++ async function nearest({ role, region, preferRegion, list } = {}) {++ const candidates = (list || await nodes({ role, region })).filter((n) => n.health && (n.health.status === 'up' || n.health.status === 'degraded'));++ if (!candidates.length) return null;++ const measured = doFetch ? (await measure(candidates)).filter((m) => m.ok) : [];++ if (measured.length) return { node: measured[0].node, rtt_ms: measured[0].rtt_ms, measured: true };++ const pick = (preferRegion && candidates.find((n) => n.location && n.location.region === preferRegion)) || candidates.find((n) => n.health.status === 'up') || candidates[0];++ return { node: pick, rtt_ms: null, measured: false };++ }++++ return { nodes, measure, nearest };++}++++module.exports = { createGeoClient };++},];const __cache = new Array(__defs.length);function __load(i) {@@@ -2621,6 +2686,6 @@ export const {startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,registry, modules, realtime, media, community, search,-- chat, ai, jobs, tools, projects,++ chat, ai, jobs, tools, projects, geo,} = sdk;export default sdk;nnew file mode 100644@@@ -0,0 +1,7 @@++// Generated by scripts/esm.js from src/geo.js. Do not edit.++import cjs from '../src/geo.js';++++export const {++ createGeoClient,++} = cjs;++export default cjs;@@@ -23,4 +23,6 @@ module.exports = {tools: require('./src/tools'),projects: require('./src/projects'),vip: require('./src/vip'),++ openre: require('./src/openre'),++ geo: require('./src/geo'),};@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.16.0",++ "version": "0.17.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.16.0",++ "version": "0.17.0","license": "MIT","devDependencies": {"@electric-sql/pglite": "^0.5.8",@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.16.0",++ "version": "0.17.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,11 @@"import": "./esm/frame.mjs","default": "./src/frame.js"},++ "./geo": {++ "types": "./types/geo.d.ts",++ "import": "./esm/geo.mjs",++ "default": "./src/geo.js"++ },"./openre": {"types": "./types/openre.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',++ 'geo.mjs': '../src/geo.js','openre.mjs': '../src/openre.js','vip.mjs': '../src/vip.js','frame.mjs': '../src/frame.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.16.0';++const SDK_VERSION = '0.17.0';module.exports = {createClient,nnew file mode 100644@@@ -0,0 +1,61 @@++'use strict';++/**++ * openvibe-sdk/geo — which OpenVibe node is closest (roadmap WS-X1 task 5, ADR-034 §12). Browser-safe.++ *++ * const geo = createGeoClient(createClient());++ * const { node, rtt_ms } = await geo.nearest({ role: 'edge-probe' }); // measured from here++ * const all = await geo.measure(await geo.nodes({ role: 'ingest' })); // [{ node, rtt_ms, ok }] fastest first++ *++ * The registry is Network's public GET /api/v1/nodes (network.node-list-result@1). Distance is measured, not++ * guessed: each node's beacon is fetched `samples` times and the best round trip counts (the first fetch also++ * pays for DNS and TLS). Only up or degraded nodes are candidates. When nothing can be measured (offline, every++ * beacon blocked) nearest() falls back to `preferRegion`, then to the first healthy node, and says so++ * (`measured: false`). Location is region-level only: the registry holds no addresses.++ */++const nowMs = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now());++++function createGeoClient(client, { baseUrl, fetch: fetchImpl = null, samples = 3, timeoutMs = 3000, now = nowMs } = {}) {++ if (!client || typeof client.json !== 'function') throw new TypeError('createGeoClient: pass an openvibe-sdk/core client');++ const doFetch = fetchImpl || (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null);++++ /** The registry's nodes, optionally for one role or region. */++ async function nodes({ role, region } = {}) {++ const r = await client.json({ service: 'network', baseUrl, path: '/api/v1/nodes', query: { role, region }, auth: false });++ return (r && r.nodes) || [];++ }++++ async function rtt(node) {++ let best = null;++ for (let i = 0; i < samples; i++) {++ const t0 = now();++ try {++ const res = await doFetch(`${node.beacon}${node.beacon.includes('?') ? '&' : '?'}t=${Date.now()}-${i}`, { cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) });++ if (!res.ok && res.status !== 204) continue;++ const ms = now() - t0;++ if (best == null || ms < best) best = ms;++ } catch { /* this sample failed; others may not */ }++ }++ return best == null ? null : Math.round(best * 10) / 10;++ }++++ /** Round trips to each node's beacon, measured in parallel: [{ node, rtt_ms, ok }], fastest first. */++ async function measure(list) {++ if (!doFetch) throw new TypeError('createGeoClient: no fetch available to measure with');++ const out = await Promise.all(list.map(async (node) => { const ms = await rtt(node); return { node, rtt_ms: ms, ok: ms != null }; }));++ return out.sort((a, b) => (a.ok === b.ok ? (a.rtt_ms ?? Infinity) - (b.rtt_ms ?? Infinity) : a.ok ? -1 : 1));++ }++++ /** The closest healthy node for a role → { node, rtt_ms, measured } or null when the registry has none. */++ async function nearest({ role, region, preferRegion, list } = {}) {++ const candidates = (list || await nodes({ role, region })).filter((n) => n.health && (n.health.status === 'up' || n.health.status === 'degraded'));++ if (!candidates.length) return null;++ const measured = doFetch ? (await measure(candidates)).filter((m) => m.ok) : [];++ if (measured.length) return { node: measured[0].node, rtt_ms: measured[0].rtt_ms, measured: true };++ const pick = (preferRegion && candidates.find((n) => n.location && n.location.region === preferRegion)) || candidates.find((n) => n.health.status === 'up') || candidates[0];++ return { node: pick, rtt_ms: null, measured: false };++ }++++ return { nodes, measure, nearest };++}++++module.exports = { createGeoClient };nnew file mode 100644@@@ -0,0 +1,64 @@++'use strict';++/** Geo: the registry read without a token, only healthy nodes are candidates, the fastest measured beacon wins++ * (best of the samples), and without any measurement nearest() falls back to preferRegion and says so. */++const assert = require('node:assert/strict');++const { stubServer, send, run } = require('./helpers');++const { createClient } = require('../src/core');++const { createGeoClient } = require('../src/geo');++++const node = (id, region, status = 'up') => ({ id, name: id, roles: ['edge-probe'], location: { region }, beacon: `https://${id}.test/beacon`, health: { status, checked_at: '2026-09-28T12:00:00Z' }, updated_at: '2026-09-28T12:00:00Z' });++const NODES = [node('oregon-1', 'us-west'), node('fra-1', 'eu-central'), node('sgp-1', 'ap-southeast', 'down'), node('nyc-1', 'us-east', 'degraded')];++++async function registry() {++ return stubServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ if (u.pathname === '/api/v1/nodes') {++ const role = u.searchParams.get('role');++ return send(res, 200, { nodes: role === 'gpu' ? [] : NODES, generated_at: '2026-09-28T12:00:00Z' });++ }++ return send(res, 404, {});++ });++}++++// A fake fetch on real timers: each beacon answers after its node's latency; fra-1's first sample is slow (DNS + TLS).++function fakeNet(latency, { fail = new Set() } = {}) {++ const seen = {};++ return {++ fetch: async (url) => {++ const id = new URL(url).hostname.split('.')[0];++ seen[id] = (seen[id] || 0) + 1;++ if (fail.has(id)) throw new Error('blocked');++ await new Promise((r) => setTimeout(r, latency[id] + (id === 'fra-1' && seen[id] === 1 ? 200 : 0)));++ return { ok: false, status: 204 };++ },++ seen,++ };++}++++run([++ ['nodes() reads the public registry without a token; nearest() measures and picks the fastest healthy node', async () => {++ const srv = await registry();++ const net = fakeNet({ 'oregon-1': 120, 'fra-1': 15, 'nyc-1': 60, 'sgp-1': 1 });++ const geo = createGeoClient(createClient({ token: 'secret', baseUrls: { network: srv.url } }), { fetch: net.fetch, samples: 3 });++ const all = await geo.nodes({ role: 'edge-probe' });++ assert.equal(all.length, 4);++ assert.equal(srv.requests[0].headers.authorization, undefined, 'the registry is public: no token is sent');++ assert.equal(new URL(srv.requests[0].url, 'http://x').searchParams.get('role'), 'edge-probe');++ const best = await geo.nearest({ role: 'edge-probe' });++ assert.deepEqual([best.node.id, best.measured], ['fra-1', true]);++ assert.ok(best.rtt_ms >= 14 && best.rtt_ms < 100, `best of the samples, not the slow first one: ${best.rtt_ms}`);++ assert.equal(net.seen['sgp-1'], undefined, 'a down node is never measured');++ const m = await geo.measure(all.slice(0, 2));++ assert.deepEqual(m.map((x) => x.node.id), ['fra-1', 'oregon-1']);++ assert.equal(await geo.nearest({ role: 'gpu' }), null, 'no node for the role');++ await srv.close();++ }],++ ['nothing measurable: preferRegion, then the first up node, marked measured: false', async () => {++ const srv = await registry();++ const net = fakeNet({}, { fail: new Set(['oregon-1', 'fra-1', 'nyc-1']) });++ const geo = createGeoClient(createClient({ baseUrls: { network: srv.url } }), { fetch: net.fetch, samples: 1 });++ assert.deepEqual(await geo.nearest({ preferRegion: 'us-east' }).then((r) => [r.node.id, r.measured, r.rtt_ms]), ['nyc-1', false, null]);++ assert.equal((await geo.nearest()).node.id, 'oregon-1');++ await srv.close();++ }],++]);@@@ -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, 27);++ assert.equal(subpaths.length, 28);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 });@@@ -11,6 +11,7 @@ import * as ai from './ai';import * as jobs from './jobs';import * as tools from './tools';import * as projects from './projects';++import * as geo from './geo';import * as core from './core';export * from './core';@@@ -26,9 +27,10 @@ export * as ai from './ai';export * as jobs from './jobs';export * as tools from './tools';export * as projects from './projects';++export * as geo from './geo';declare const sdk: typeof core & {auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;-- media: typeof media; community: typeof community; search: typeof search; chat: typeof chat; ai: typeof ai; jobs: typeof jobs; tools: typeof tools; projects: typeof projects;++ media: typeof media; community: typeof community; search: typeof search; chat: typeof chat; ai: typeof ai; jobs: typeof jobs; tools: typeof tools; projects: typeof projects; geo: typeof geo;};export default sdk;nnew file mode 100644@@@ -0,0 +1,18 @@++import type { OpenVibeClient } from './core';++++/** Contracts network.node@1. */++export interface OpenVibeNode {++ id: string; name: string;++ roles: Array<'web' | 'app' | 'data' | 'media-worker' | 'ingest' | 'edge-probe' | 'edge-relay' | 'edge-cache' | 'gpu' | 'staging' | 'ci'>;++ location: { region: string; country?: string; city?: string; lat?: number; lon?: number };++ provider?: string; beacon: string;++ health: { status: 'up' | 'degraded' | 'down' | 'unknown'; checked_at: string };++ updated_at: string;++}++export interface Measured { node: OpenVibeNode; rtt_ms: number | null; ok: boolean }++export interface GeoClient {++ nodes(opts?: { role?: string; region?: string }): Promise<OpenVibeNode[]>;++ measure(nodes: OpenVibeNode[]): Promise<Measured[]>;++ nearest(opts?: { role?: string; region?: string; preferRegion?: string; list?: OpenVibeNode[] }): Promise<{ node: OpenVibeNode; rtt_ms: number | null; measured: boolean } | null>;++}++export function createGeoClient(client: OpenVibeClient, opts?: { baseUrl?: string; fetch?: typeof fetch; samples?: number; timeoutMs?: number; now?: () => number }): GeoClient;@@@ -15,3 +15,4 @@ export * as tools from './tools';export * as projects from './projects';export * as vip from './vip';export * as openre from './openre';++export * as geo from './geo';
@@ -4,6 +4,15 @@ All notable changes to `openvibe-sdk`. The package follows semver; while it is `
@@ -4,6 +4,15 @@ All notable changes to `openvibe-sdk`. The package follows semver; while it is `
release may change an API and says so here.
release may change an API and says so here.
+
+## 0.17.0 (2026-09-28)
+
+- **`openvibe-sdk/geo`** (browser and server): `createGeoClient(client, { samples?, timeoutMs? })` finds the closest OpenVibe node (roadmap WS-X1 task 5).
+ - `nodes({ role?, region? })` reads Network's public node registry, `GET /api/v1/nodes` (`network.node-list-result@1`), without sending a token.
+ - `measure(nodes)` times each node's beacon several times (the best sample counts, since the first also pays for DNS and TLS) and returns the nodes fastest first.
+ - `nearest({ role?, region?, preferRegion? })` returns the fastest measured node that is up or degraded. When nothing can be measured, it falls back to `preferRegion`, then to the first healthy node, and returns `measured: false`.
+- The root export and the browser bundle include `geo`. The root export also includes `openre`.
+
## 0.16.0 (2026-09-28)
## 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.
- **`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.
@@ -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/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/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` |
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.16.0",
+ "version": "0.17.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, openre and vip clients",
+ "community, search, chat, ai, jobs, tools, projects, modules, identity, openre, geo 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)",
@@ -19,4 +19,5 @@ module.exports = {
@@ -19,4 +19,5 @@ module.exports = {
jobs: require('./src/jobs'),
jobs: require('./src/jobs'),
tools: require('./src/tools'),
tools: require('./src/tools'),
projects: require('./src/projects'),
projects: require('./src/projects'),
+ geo: require('./src/geo'),
};
};
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.16.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.17.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, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
const __defs = [
const __defs = [
// ── 0: browser.js ──────────────────────────────────────────────────
// ── 0: browser.js ──────────────────────────────────────────────────
@@ -26,6 +26,7 @@ module.exports = {
@@ -26,6 +26,7 @@ module.exports = {
jobs: __load(18),
jobs: __load(18),
tools: __load(20),
tools: __load(20),
projects: __load(21),
projects: __load(21),
+ geo: __load(22),
};
};
},
},
// ── 1: src/core/index.js ───────────────────────────────────────────
// ── 1: src/core/index.js ───────────────────────────────────────────
@@ -40,7 +41,7 @@ const { paginate, offsetPager } = __load(7);
@@ -40,7 +41,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.16.0';
+const SDK_VERSION = '0.17.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -2605,6 +2606,70 @@ function createProjectsClient(client, { baseUrl } = {}) {
@@ -2605,6 +2606,70 @@ function createProjectsClient(client, { baseUrl } = {}) {
module.exports = { createProjectsClient };
module.exports = { createProjectsClient };
},
},
+// ── 22: src/geo.js ──────────────────────────────────────────────────
+function (module, exports) {
+'use strict';
+/**
+ * openvibe-sdk/geo — which OpenVibe node is closest (roadmap WS-X1 task 5, ADR-034 §12). Browser-safe.
+ *
+ * const geo = createGeoClient(createClient());
+ * const { node, rtt_ms } = await geo.nearest({ role: 'edge-probe' }); // measured from here
+ * const all = await geo.measure(await geo.nodes({ role: 'ingest' })); // [{ node, rtt_ms, ok }] fastest first
+ *
+ * The registry is Network's public GET /api/v1/nodes (network.node-list-result@1). Distance is measured, not
+ * guessed: each node's beacon is fetched `samples` times and the best round trip counts (the first fetch also
+ * pays for DNS and TLS). Only up or degraded nodes are candidates. When nothing can be measured (offline, every
+ * beacon blocked) nearest() falls back to `preferRegion`, then to the first healthy node, and says so
+ * (`measured: false`). Location is region-level only: the registry holds no addresses.
+ */
+const nowMs = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now());
+
+function createGeoClient(client, { baseUrl, fetch: fetchImpl = null, samples = 3, timeoutMs = 3000, now = nowMs } = {}) {
+ if (!client || typeof client.json !== 'function') throw new TypeError('createGeoClient: pass an openvibe-sdk/core client');
+ const doFetch = fetchImpl || (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null);
+
+ /** The registry's nodes, optionally for one role or region. */
+ async function nodes({ role, region } = {}) {
+ const r = await client.json({ service: 'network', baseUrl, path: '/api/v1/nodes', query: { role, region }, auth: false });
+ return (r && r.nodes) || [];
+ }
+
+ async function rtt(node) {
+ let best = null;
+ for (let i = 0; i < samples; i++) {
+ const t0 = now();
+ try {
+ const res = await doFetch(`${node.beacon}${node.beacon.includes('?') ? '&' : '?'}t=${Date.now()}-${i}`, { cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) });
+ if (!res.ok && res.status !== 204) continue;
+ const ms = now() - t0;
+ if (best == null || ms < best) best = ms;
+ } catch { /* this sample failed; others may not */ }
+ }
+ return best == null ? null : Math.round(best * 10) / 10;
+ }
+
+ /** Round trips to each node's beacon, measured in parallel: [{ node, rtt_ms, ok }], fastest first. */
+ async function measure(list) {
+ if (!doFetch) throw new TypeError('createGeoClient: no fetch available to measure with');
+ const out = await Promise.all(list.map(async (node) => { const ms = await rtt(node); return { node, rtt_ms: ms, ok: ms != null }; }));
+ return out.sort((a, b) => (a.ok === b.ok ? (a.rtt_ms ?? Infinity) - (b.rtt_ms ?? Infinity) : a.ok ? -1 : 1));
+ }
+
+ /** The closest healthy node for a role → { node, rtt_ms, measured } or null when the registry has none. */
+ async function nearest({ role, region, preferRegion, list } = {}) {
+ const candidates = (list || await nodes({ role, region })).filter((n) => n.health && (n.health.status === 'up' || n.health.status === 'degraded'));
+ if (!candidates.length) return null;
+ const measured = doFetch ? (await measure(candidates)).filter((m) => m.ok) : [];
+ if (measured.length) return { node: measured[0].node, rtt_ms: measured[0].rtt_ms, measured: true };
+ const pick = (preferRegion && candidates.find((n) => n.location && n.location.region === preferRegion)) || candidates.find((n) => n.health.status === 'up') || candidates[0];
+ return { node: pick, rtt_ms: null, measured: false };
+ }
+
+ return { nodes, measure, nearest };
+}
+
+module.exports = { createGeoClient };
+},
];
];
const __cache = new Array(__defs.length);
const __cache = new Array(__defs.length);
function __load(i) {
function __load(i) {
@@ -2621,6 +2686,6 @@ export const {
@@ -2621,6 +2686,6 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, modules, realtime, media, community, search,
registry, modules, realtime, media, community, search,
- chat, ai, jobs, tools, projects,
+ chat, ai, jobs, tools, projects, geo,
} = sdk;
} = sdk;
export default sdk;
export default sdk;
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+// Generated by scripts/esm.js from src/geo.js. Do not edit.
+import cjs from '../src/geo.js';
+
+export const {
+ createGeoClient,
+} = cjs;
+export default cjs;
@@ -23,4 +23,6 @@ module.exports = {
@@ -23,4 +23,6 @@ module.exports = {
tools: require('./src/tools'),
tools: require('./src/tools'),
projects: require('./src/projects'),
projects: require('./src/projects'),
vip: require('./src/vip'),
vip: require('./src/vip'),
+ openre: require('./src/openre'),
+ geo: require('./src/geo'),
};
};
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.16.0",
+ "version": "0.17.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.16.0",
+ "version": "0.17.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.16.0",
+ "version": "0.17.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,11 @@
@@ -136,6 +136,11 @@
"import": "./esm/frame.mjs",
"import": "./esm/frame.mjs",
"default": "./src/frame.js"
"default": "./src/frame.js"
},
},
+ "./geo": {
+ "types": "./types/geo.d.ts",
+ "import": "./esm/geo.mjs",
+ "default": "./src/geo.js"
+ },
"./openre": {
"./openre": {
"types": "./types/openre.d.ts",
"types": "./types/openre.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',
+ 'geo.mjs': '../src/geo.js',
'openre.mjs': '../src/openre.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',
@@ -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.16.0';
+const SDK_VERSION = '0.17.0';
module.exports = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+'use strict';
+/**
+ * openvibe-sdk/geo — which OpenVibe node is closest (roadmap WS-X1 task 5, ADR-034 §12). Browser-safe.
+ *
+ * const geo = createGeoClient(createClient());
+ * const { node, rtt_ms } = await geo.nearest({ role: 'edge-probe' }); // measured from here
+ * const all = await geo.measure(await geo.nodes({ role: 'ingest' })); // [{ node, rtt_ms, ok }] fastest first
+ *
+ * The registry is Network's public GET /api/v1/nodes (network.node-list-result@1). Distance is measured, not
+ * guessed: each node's beacon is fetched `samples` times and the best round trip counts (the first fetch also
+ * pays for DNS and TLS). Only up or degraded nodes are candidates. When nothing can be measured (offline, every
+ * beacon blocked) nearest() falls back to `preferRegion`, then to the first healthy node, and says so
+ * (`measured: false`). Location is region-level only: the registry holds no addresses.
+ */
+const nowMs = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now());
+
+function createGeoClient(client, { baseUrl, fetch: fetchImpl = null, samples = 3, timeoutMs = 3000, now = nowMs } = {}) {
+ if (!client || typeof client.json !== 'function') throw new TypeError('createGeoClient: pass an openvibe-sdk/core client');
+ const doFetch = fetchImpl || (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null);
+
+ /** The registry's nodes, optionally for one role or region. */
+ async function nodes({ role, region } = {}) {
+ const r = await client.json({ service: 'network', baseUrl, path: '/api/v1/nodes', query: { role, region }, auth: false });
+ return (r && r.nodes) || [];
+ }
+
+ async function rtt(node) {
+ let best = null;
+ for (let i = 0; i < samples; i++) {
+ const t0 = now();
+ try {
+ const res = await doFetch(`${node.beacon}${node.beacon.includes('?') ? '&' : '?'}t=${Date.now()}-${i}`, { cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) });
+ if (!res.ok && res.status !== 204) continue;
+ const ms = now() - t0;
+ if (best == null || ms < best) best = ms;
+ } catch { /* this sample failed; others may not */ }
+ }
+ return best == null ? null : Math.round(best * 10) / 10;
+ }
+
+ /** Round trips to each node's beacon, measured in parallel: [{ node, rtt_ms, ok }], fastest first. */
+ async function measure(list) {
+ if (!doFetch) throw new TypeError('createGeoClient: no fetch available to measure with');
+ const out = await Promise.all(list.map(async (node) => { const ms = await rtt(node); return { node, rtt_ms: ms, ok: ms != null }; }));
+ return out.sort((a, b) => (a.ok === b.ok ? (a.rtt_ms ?? Infinity) - (b.rtt_ms ?? Infinity) : a.ok ? -1 : 1));
+ }
+
+ /** The closest healthy node for a role → { node, rtt_ms, measured } or null when the registry has none. */
+ async function nearest({ role, region, preferRegion, list } = {}) {
+ const candidates = (list || await nodes({ role, region })).filter((n) => n.health && (n.health.status === 'up' || n.health.status === 'degraded'));
+ if (!candidates.length) return null;
+ const measured = doFetch ? (await measure(candidates)).filter((m) => m.ok) : [];
+ if (measured.length) return { node: measured[0].node, rtt_ms: measured[0].rtt_ms, measured: true };
+ const pick = (preferRegion && candidates.find((n) => n.location && n.location.region === preferRegion)) || candidates.find((n) => n.health.status === 'up') || candidates[0];
+ return { node: pick, rtt_ms: null, measured: false };
+ }
+
+ return { nodes, measure, nearest };
+}
+
+module.exports = { createGeoClient };
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+'use strict';
+/** Geo: the registry read without a token, only healthy nodes are candidates, the fastest measured beacon wins
+ * (best of the samples), and without any measurement nearest() falls back to preferRegion and says so. */
+const assert = require('node:assert/strict');
+const { stubServer, send, run } = require('./helpers');
+const { createClient } = require('../src/core');
+const { createGeoClient } = require('../src/geo');
+
+const node = (id, region, status = 'up') => ({ id, name: id, roles: ['edge-probe'], location: { region }, beacon: `https://${id}.test/beacon`, health: { status, checked_at: '2026-09-28T12:00:00Z' }, updated_at: '2026-09-28T12:00:00Z' });
+const NODES = [node('oregon-1', 'us-west'), node('fra-1', 'eu-central'), node('sgp-1', 'ap-southeast', 'down'), node('nyc-1', 'us-east', 'degraded')];
+
+async function registry() {
+ return stubServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ if (u.pathname === '/api/v1/nodes') {
+ const role = u.searchParams.get('role');
+ return send(res, 200, { nodes: role === 'gpu' ? [] : NODES, generated_at: '2026-09-28T12:00:00Z' });
+ }
+ return send(res, 404, {});
+ });
+}
+
+// A fake fetch on real timers: each beacon answers after its node's latency; fra-1's first sample is slow (DNS + TLS).
+function fakeNet(latency, { fail = new Set() } = {}) {
+ const seen = {};
+ return {
+ fetch: async (url) => {
+ const id = new URL(url).hostname.split('.')[0];
+ seen[id] = (seen[id] || 0) + 1;
+ if (fail.has(id)) throw new Error('blocked');
+ await new Promise((r) => setTimeout(r, latency[id] + (id === 'fra-1' && seen[id] === 1 ? 200 : 0)));
+ return { ok: false, status: 204 };
+ },
+ seen,
+ };
+}
+
+run([
+ ['nodes() reads the public registry without a token; nearest() measures and picks the fastest healthy node', async () => {
+ const srv = await registry();
+ const net = fakeNet({ 'oregon-1': 120, 'fra-1': 15, 'nyc-1': 60, 'sgp-1': 1 });
+ const geo = createGeoClient(createClient({ token: 'secret', baseUrls: { network: srv.url } }), { fetch: net.fetch, samples: 3 });
+ const all = await geo.nodes({ role: 'edge-probe' });
+ assert.equal(all.length, 4);
+ assert.equal(srv.requests[0].headers.authorization, undefined, 'the registry is public: no token is sent');
+ assert.equal(new URL(srv.requests[0].url, 'http://x').searchParams.get('role'), 'edge-probe');
+ const best = await geo.nearest({ role: 'edge-probe' });
+ assert.deepEqual([best.node.id, best.measured], ['fra-1', true]);
+ assert.ok(best.rtt_ms >= 14 && best.rtt_ms < 100, `best of the samples, not the slow first one: ${best.rtt_ms}`);
+ assert.equal(net.seen['sgp-1'], undefined, 'a down node is never measured');
+ const m = await geo.measure(all.slice(0, 2));
+ assert.deepEqual(m.map((x) => x.node.id), ['fra-1', 'oregon-1']);
+ assert.equal(await geo.nearest({ role: 'gpu' }), null, 'no node for the role');
+ await srv.close();
+ }],
+ ['nothing measurable: preferRegion, then the first up node, marked measured: false', async () => {
+ const srv = await registry();
+ const net = fakeNet({}, { fail: new Set(['oregon-1', 'fra-1', 'nyc-1']) });
+ const geo = createGeoClient(createClient({ baseUrls: { network: srv.url } }), { fetch: net.fetch, samples: 1 });
+ assert.deepEqual(await geo.nearest({ preferRegion: 'us-east' }).then((r) => [r.node.id, r.measured, r.rtt_ms]), ['nyc-1', false, null]);
+ assert.equal((await geo.nearest()).node.id, 'oregon-1');
+ await srv.close();
+ }],
+]);
@@ -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, 27);
+ assert.equal(subpaths.length, 28);
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 });
@@ -11,6 +11,7 @@ import * as ai from './ai';
@@ -11,6 +11,7 @@ import * as ai from './ai';
import * as jobs from './jobs';
import * as jobs from './jobs';
import * as tools from './tools';
import * as tools from './tools';
import * as projects from './projects';
import * as projects from './projects';
+import * as geo from './geo';
import * as core from './core';
import * as core from './core';
export * from './core';
export * from './core';
@@ -26,9 +27,10 @@ export * as ai from './ai';
@@ -26,9 +27,10 @@ export * as ai from './ai';
export * as jobs from './jobs';
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 geo from './geo';
declare const sdk: typeof core & {
declare const sdk: typeof core & {
auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
- media: typeof media; community: typeof community; search: typeof search; chat: typeof chat; ai: typeof ai; jobs: typeof jobs; tools: typeof tools; projects: typeof projects;
+ media: typeof media; community: typeof community; search: typeof search; chat: typeof chat; ai: typeof ai; jobs: typeof jobs; tools: typeof tools; projects: typeof projects; geo: typeof geo;
};
};
export default sdk;
export default sdk;
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+import type { OpenVibeClient } from './core';
+
+/** Contracts network.node@1. */
+export interface OpenVibeNode {
+ id: string; name: string;
+ roles: Array<'web' | 'app' | 'data' | 'media-worker' | 'ingest' | 'edge-probe' | 'edge-relay' | 'edge-cache' | 'gpu' | 'staging' | 'ci'>;
+ location: { region: string; country?: string; city?: string; lat?: number; lon?: number };
+ provider?: string; beacon: string;
+ health: { status: 'up' | 'degraded' | 'down' | 'unknown'; checked_at: string };
+ updated_at: string;
+}
+export interface Measured { node: OpenVibeNode; rtt_ms: number | null; ok: boolean }
+export interface GeoClient {
+ nodes(opts?: { role?: string; region?: string }): Promise<OpenVibeNode[]>;
+ measure(nodes: OpenVibeNode[]): Promise<Measured[]>;
+ nearest(opts?: { role?: string; region?: string; preferRegion?: string; list?: OpenVibeNode[] }): Promise<{ node: OpenVibeNode; rtt_ms: number | null; measured: boolean } | null>;
+}
+export function createGeoClient(client: OpenVibeClient, opts?: { baseUrl?: string; fetch?: typeof fetch; samples?: number; timeoutMs?: number; now?: () => number }): GeoClient;
@@ -15,3 +15,4 @@ export * as tools from './tools';
@@ -15,3 +15,4 @@ 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';
export * as openre from './openre';
+export * as geo from './geo';