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.

OP OpenVibers committed 28 Sept 2026 · 922eba62de · 17 files, +249 −12


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 3e3ebb5..a9f0276 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
ddiff --git a/README.md b/README.md
iindex 3f3f687..ed982c8 100644
---- a/README.md
++++ b/README.md
@@@ -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` |
ddiff --git a/STATUS.json b/STATUS.json
iindex cb02d17..1c6550c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/browser.js b/browser.js
iindex 9c06bb0..fbb8c62 100644
---- a/browser.js
++++ b/browser.js
@@@ -19,4 +19,5 @@ module.exports = {
jobs: require('./src/jobs'),
tools: require('./src/tools'),
projects: require('./src/projects'),
++ geo: require('./src/geo'),
};
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex d48939e..a9cab2c 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -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;
ddiff --git a/esm/geo.mjs b/esm/geo.mjs
nnew file mode 100644
iindex 0000000..b7156e8
---- /dev/null
++++ b/esm/geo.mjs
@@@ -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;
ddiff --git a/index.js b/index.js
iindex 4882e6d..d1f5c25 100644
---- a/index.js
++++ b/index.js
@@@ -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'),
};
ddiff --git a/package-lock.json b/package-lock.json
iindex 332494b..42673f6 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex e78f315..ff52c3d 100644
---- a/package.json
++++ b/package.json
@@@ -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,
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 3230db9..8a6dddb 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -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',
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 7714af7..50445ee 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.16.0';
++const SDK_VERSION = '0.17.0';
module.exports = {
createClient,
ddiff --git a/src/geo.js b/src/geo.js
nnew file mode 100644
iindex 0000000..dce697e
---- /dev/null
++++ b/src/geo.js
@@@ -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 };
ddiff --git a/test/geo.test.js b/test/geo.test.js
nnew file mode 100644
iindex 0000000..d71fb25
---- /dev/null
++++ b/test/geo.test.js
@@@ -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();
++ }],
++]);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 9886eee..43eec76 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -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 });
ddiff --git a/types/bundle.d.ts b/types/bundle.d.ts
iindex 8e1bf09..4ef620d 100644
---- a/types/bundle.d.ts
++++ b/types/bundle.d.ts
@@@ -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;
ddiff --git a/types/geo.d.ts b/types/geo.d.ts
nnew file mode 100644
iindex 0000000..7452944
---- /dev/null
++++ b/types/geo.d.ts
@@@ -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;
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex 668bc1c..4a5c021 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -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';