openvibers/openvibe.sdk Commits

Resumable Media upload facade and UI progress entry (#155)

OP OpenVibers committed 4 days ago · 319b9bae96 · 26 files, +253 −64

* Expose resumable Media uploads and shared progress UI

* Keep media freeze and expose upload facade as new subpath

* 0.120.0

* 0.120.0: pack counts 98 subpaths; the bundle declares mediaUpload and ui

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex df8ece9..d9df8b8 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,9 @@
# Changelog
++## 0.120.0 (2026-11-09)
++
++- createUploadClient(): resumable Media uploads in 8 MiB parts, three at a time, with a session reference to resume; ov.ui.progress(el)
++
## 0.119.0 (2026-11-09)
- openvibe-sdk/wallet: the wallet, Vibes, the store and items, through Billing and Inventory with the person's bearer and idempotency keys
ddiff --git a/README.md b/README.md
iindex 9e5f34f..a72c098 100644
---- a/README.md
++++ b/README.md
@@@ -10,7 +10,7 @@ npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.119.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.120.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
@@@ -19,7 +19,7 @@ npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags
After v0.97.1 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.119.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.120.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
@@@ -109,6 +109,23 @@ const file = await media.upload(fs.readFileSync('logo.png'), { filename: 'logo.p
file.public_url; // https://openvibe.media/f/<key>
```
++For large files, use Media's resumable multipart object API. The uploader uses 8 MiB parts,
++three concurrent requests, retries missing parts, and exposes the upload reference before
++any bytes leave so the app can persist it. Keep the credential on a server.
++
++```js
++const uploader = sdk.mediaUpload.createUploadClient({ app: unverifiedClaims.project_id, tokenClient: tokens });
++const object = await uploader.upload(file, {
++ onSession: ({ objectId, uploadId }) => saveUploadReference({ objectId, uploadId }),
++ onProgress: ({ uploadedBytes, totalBytes }) => reportProgress(uploadedBytes, totalBytes),
++});
++// After a restart: uploader.resume(savedReference, sameFile, { onProgress });
++```
++
++On a page that loads the pinned `ov-upload.js` and `ov-upload.css` from
++`openvibe-shared`, `sdk.ui.progress(el)` mounts the same progress component used
++across OpenVibe sites.
++
The token's `env` picks the tenant: a sandbox app's files go to the project's sandbox tenant (`app_id: '<prj_…>-sandbox'`) and are never served publicly. Media answers them with `sandbox: true`, a signed, expiring `url` and `url_expires_at`; the client adds `signed_url` (the same URL) and sets `public_url` to `null`. Uploading and deleting need `media.object.upload`, listing and getting `media.object.read`.
App events (`events.app.publish | read | subscribe`) live under the project's own topic `app.<project_key>.*` (`project_key` = `p` + the lowercased project ULID) with `source: app-<lowercased app ULID>`. `createAppEvents()` fills that in, and scopes pulls and subscriptions to the project:
@@@ -575,7 +592,7 @@ shows the cards in `GET /api/frame`.
- **Community comments on other content, the forum and Pulse** are being built in Community right now. Only paste comments are wrapped. The rest is a TODO in `src/community.js` until those APIs settle.
- **Media:** the v1 files API, the object API v2 (`createObjectsClient`) and the public URL helpers are wrapped. Retention holds on v2 objects are not (app keys only). VOD, clip, thumbnail and admin-storage routes are not wrapped because apps reach them only through Live and Media's own servers. Media's own paste API is not wrapped because pastes moved to Community (ADR-011). The mock platform (`openvibe-sdk/testing`) models Media's v1 files only, not v2 objects.
--- **Media from the browser:** Media refuses user JWTs on `/api/v1/:app/files`, so a browser uploads through its own app server. That server holds the app key and names the user with `actingUserId`.
++- **Media from the browser:** Media refuses user JWTs on the v1 files and v2 objects APIs, so a browser uploads through its own app server. That server holds the app credential and names the user with `actingUserId` or a subject.
- **Network coins, notifications and legacy-map writes** are internal service-to-service routes with no public capability, so they are not wrapped. Staff-only paste routes are also internal: admin stats, bulk, censor and the AI pass.
- **Billing and Games** from the original charter come when those services publish their capabilities in Contracts (Live has its module since v0.52.0). Codes' tasks (they run code on a person's own machine) stay first-party, and OpenVibe.Watch is not built yet; Host and News have modules for their public reads, while editing stays first-party. Blog opened its posts to apps (openvibe-contracts 0.206.0, `openvibe-sdk/blog`), then Reviews and the Wiki their reads (0.207.0), then Coupons, Deals and Trade what a person does for themselves (0.208.0), then Space its sites (0.209.0). News' published stories need no capability, but its editorial reads stay first-party. Chat is wrapped over REST as the token's person (`openvibe-sdk/chat`, v0.11.0), but it has no app principal (`chat.message.send` is `first-party`) and the mock platform has no Chat.
- **Tools** are not wrapped one by one: `openvibe-sdk/tools` runs any tool with `api: true` by id, and its descriptor (`tools.get(id)`) gives the input schema, files and limits. `openvibe-sdk/jobs` still submits any job `type` directly. The YouTube downloader is page-only (`api: false`), so it is not in the SDK.
ddiff --git a/STATUS.json b/STATUS.json
iindex b8c2a8b..953ea18 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.119.0",
++ "version": "0.120.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",
ddiff --git a/browser.js b/browser.js
iindex e450e0b..2fed999 100644
---- a/browser.js
++++ b/browser.js
@@@ -16,6 +16,8 @@ module.exports = {
realtime: require('./src/realtime'),
media: require('./src/media'),
cam: require('./src/cam'),
++ mediaUpload: require('./src/media-upload'),
++ ui: require('./src/ui'),
community: require('./src/community'),
homeFeed: require('./src/home-feed'),
bot: require('./src/bot'),
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex b89484e..ebc3528 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.119.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.120.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
--// (auth, locker, registry, trust, modules, realtime, media, cam, community, homeFeed, bot, search, bench, meme, vision, themes, design, redact, chat, ai, credentials, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
++// (auth, locker, registry, trust, modules, realtime, media, cam, mediaUpload, ui, community, homeFeed, bot, search, bench, meme, vision, themes, design, redact, chat, ai, credentials, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
const __defs = [
// ── 0: browser.js ──────────────────────────────────────────────────
@@@ -23,23 +23,25 @@ module.exports = {
realtime: __load(24),
media: __load(25),
cam: __load(27),
-- community: __load(28),
-- homeFeed: __load(29),
-- bot: __load(30),
-- search: __load(31),
-- bench: __load(32),
-- meme: __load(33),
-- vision: __load(34),
-- themes: __load(35),
-- design: __load(36),
-- redact: __load(37),
-- chat: __load(38),
-- ai: __load(39),
-- credentials: __load(40),
-- jobs: __load(41),
-- tools: __load(43),
-- projects: __load(44),
-- geo: __load(45),
++ mediaUpload: __load(28),
++ ui: __load(29),
++ community: __load(30),
++ homeFeed: __load(31),
++ bot: __load(32),
++ search: __load(33),
++ bench: __load(34),
++ meme: __load(35),
++ vision: __load(36),
++ themes: __load(37),
++ design: __load(38),
++ redact: __load(39),
++ chat: __load(40),
++ ai: __load(41),
++ credentials: __load(42),
++ jobs: __load(43),
++ tools: __load(45),
++ projects: __load(46),
++ geo: __load(47),
};
},
// ── 1: src/core/index.js ───────────────────────────────────────────
@@@ -54,7 +56,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.119.0';
++const SDK_VERSION = '0.120.0';
module.exports = {
createClient,
@@@ -2856,6 +2858,7 @@ function (module, exports) {
* delete, media.object.read to list and get. Media refuses a user JWT here: a browser uploads
* through its own app server, which names the user with X-OV-User-Id (the `actingUserId` option).
* Browser-safe code (FormData/Blob), but the credentials it needs belong on a server.
++ * The upload facade lives at openvibe-sdk/media-upload so this frozen module keeps its names.
*
* Sandbox app tokens land in the project's sandbox tenant (`<prj_…>-sandbox`, shown as `app_id`),
* whose files are never served publicly: Media answers them with `sandbox: true`, a signed,
@@@ -3185,6 +3188,7 @@ function createObjectsClient({
}
const up = created.upload || {};
if (up.multipart) {
++ if (opts.onSession) await opts.onSession({ objectId: created.id, uploadId: up.multipart.upload_id });
return finishMultipart(created.id, up.multipart.upload_id, sessionIo(up.multipart, { objectId: created.id }), body, { signal, onProgress, contentHash, total });
}
await presigned(up.url, {
@@@ -3368,7 +3372,40 @@ function defineOvCamera(options = {}) {
module.exports = { defineOvCamera, OV_CAMERA_SRC, CAM_KIT_SRC, CAM_KIT_SRI, loadCamKit, open };
},
--// ── 28: src/community.js ────────────────────────────────────────────
++// ── 28: src/media-upload.js ─────────────────────────────────────────
++function (module, exports) {
++'use strict';
++const { createObjectsClient } = __load(25);
++const MIB = 1024 * 1024;
++
++/** Media's resumable multipart transport, configured for 8 MiB parts and three in flight. */
++function createUploadClient(config) {
++ const objects = createObjectsClient({ partSize: 8 * MIB, concurrency: 3, ...config });
++ return {
++ upload(file, options = {}) {
++ return objects.upload(file, { kind: 'file', filename: file && file.name, ...options });
++ },
++ resume(ref, file, options) { return objects.resume(ref, file, options); },
++ objects,
++ };
++}
++
++module.exports = { createUploadClient };
++},
++// ── 29: src/ui.js ───────────────────────────────────────────────────
++function (module, exports) {
++'use strict';
++
++/** Mount the shared OpenVibe progress UI after loading /shared/ov-upload.js and .css. */
++function progress(el, options) {
++ const upload = globalThis.OVUpload;
++ if (!upload || typeof upload.progress !== 'function') throw new Error('ov.ui.progress: load /shared/ov-upload.js first');
++ return upload.progress(el, options);
++}
++
++module.exports = { progress };
++},
++// ── 30: src/community.js ────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3503,7 +3540,7 @@ function createCommunityClient(client, defaults = {}) {
module.exports = { createCommunityClient, actingHeaders };
},
--// ── 29: src/home-feed.js ────────────────────────────────────────────
++// ── 31: src/home-feed.js ────────────────────────────────────────────
function (module, exports) {
'use strict';
/** The menu's portable Home layout, network day counts and personal wall. Pass a user-token core client;
@@@ -3530,7 +3567,7 @@ function createHomeFeedClient(client, { networkBaseUrl, communityBaseUrl } = {})
module.exports = { createHomeFeedClient };
},
--// ── 30: src/bot.js ──────────────────────────────────────────────────
++// ── 32: src/bot.js ──────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3752,7 +3789,7 @@ function createBotClient(client, defaults = {}) {
module.exports = { createBotClient, actingHeaders };
},
--// ── 31: src/search.js ───────────────────────────────────────────────
++// ── 33: src/search.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3865,7 +3902,7 @@ function createSearchClient(client, defaults = {}) {
module.exports = { createSearchClient, queryOf, fabricOf };
},
--// ── 32: src/bench.js ────────────────────────────────────────────────
++// ── 34: src/bench.js ────────────────────────────────────────────────
function (module, exports) {
'use strict';
/** openvibe-sdk/bench: OpenVibe.Bench's JSON API and streamed open dataset. Browser-safe. */
@@@ -3966,7 +4003,7 @@ function createBenchClient(client, defaults = {}) {
module.exports = { createBenchClient };
},
--// ── 33: src/meme.js ─────────────────────────────────────────────────
++// ── 35: src/meme.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -4034,7 +4071,7 @@ function createMemeClient(client, defaults = {}) {
module.exports = { createMemeClient };
},
--// ── 34: src/vision.js ───────────────────────────────────────────────
++// ── 36: src/vision.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -4192,7 +4229,7 @@ function pick(o, keys) {
module.exports = { createVisionClient, DEFAULT_AUDIENCE };
},
--// ── 35: src/themes.js ───────────────────────────────────────────────
++// ── 37: src/themes.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -4342,7 +4379,7 @@ function followTheme(fn, opts = {}) {
module.exports = { createThemesClient, followTheme, readTheme, applyTheme, seasonOn, onColor, DISPLAY_OPTIONS, TOKENS };
},
--// ── 36: src/design.js ───────────────────────────────────────────────
++// ── 38: src/design.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
@@@ -4409,7 +4446,7 @@ function createDesignClient({ accessToken = null, baseUrl = DEFAULT_DESIGN_URL,
module.exports = { createDesignClient, DEFAULT_DESIGN_URL };
},
--// ── 37: src/redact.js ───────────────────────────────────────────────
++// ── 39: src/redact.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -4656,7 +4693,7 @@ function onlySecret(text) {
module.exports = { redact, onlySecret };
},
--// ── 38: src/chat.js ─────────────────────────────────────────────────
++// ── 40: src/chat.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -4904,7 +4941,7 @@ function createFeedPublisher(client, { feed, key, baseUrl } = {}) {
module.exports = { createChatClient, createInboxVisitor, createFeedPublisher };
},
--// ── 39: src/ai.js ───────────────────────────────────────────────────
++// ── 41: src/ai.js ───────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -5036,7 +5073,7 @@ function createAiClient(client, defaults = {}) {
module.exports = { createAiClient, TERMINAL, OPS, CLASSES };
},
--// ── 40: src/credentials.js ──────────────────────────────────────────
++// ── 42: src/credentials.js ──────────────────────────────────────────
function (module, exports) {
'use strict';
@@@ -5054,7 +5091,7 @@ function createCredentialsClient(client, defaults = {}) {
module.exports = { createCredentialsClient };
},
--// ── 41: src/jobs.js ─────────────────────────────────────────────────
++// ── 43: src/jobs.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -5092,7 +5129,7 @@ function (module, exports) {
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
--const { appendFiles } = __load(42);
++const { appendFiles } = __load(44);
const { parseSSE } = __load(24);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
@@@ -5256,7 +5293,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
},
--// ── 42: src/core/form.js ────────────────────────────────────────────
++// ── 44: src/core/form.js ────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -5290,7 +5327,7 @@ function appendFiles(form, list, where) {
module.exports = { isBlob, isUpload, appendFiles };
},
--// ── 43: src/tools.js ────────────────────────────────────────────────
++// ── 45: src/tools.js ────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -5324,8 +5361,8 @@ function (module, exports) {
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
--const { isUpload, appendFiles } = __load(42);
--const { createJobsClient } = __load(41);
++const { isUpload, appendFiles } = __load(44);
++const { createJobsClient } = __load(43);
const enc = encodeURIComponent;
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
@@@ -5585,7 +5622,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'ope
module.exports = { createToolsClient, ToolRunError, isToolRunError };
},
--// ── 44: src/projects.js ─────────────────────────────────────────────
++// ── 46: src/projects.js ─────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -5701,7 +5738,7 @@ function createProjectsClient(client, { baseUrl } = {}) {
module.exports = { createProjectsClient };
},
--// ── 45: src/geo.js ──────────────────────────────────────────────────
++// ── 47: src/geo.js ──────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -5781,9 +5818,9 @@ export const {
parseTraceparent, startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey,
isActingSubjectId, satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION,
auth, locker, registry, trust, modules, realtime,
-- media, cam, community, homeFeed, bot, search,
-- bench, meme, vision, themes, design, redact,
-- chat, ai, credentials, jobs, tools, projects,
-- geo,
++ media, cam, mediaUpload, ui, community, homeFeed,
++ bot, search, bench, meme, vision, themes,
++ design, redact, chat, ai, credentials, jobs,
++ tools, projects, geo,
} = sdk;
export default sdk;
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex 54f7af4..53a58d9 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -251,6 +251,11 @@
"mediaUrls"
]
},
++ "mediaUpload": {
++ "object": [
++ "createUploadClient"
++ ]
++ },
"meme": {
"object": [
"createMemeClient"
@@@ -409,6 +414,11 @@
"isNew"
]
},
++ "ui": {
++ "object": [
++ "progress"
++ ]
++ },
"ulid": "function",
"usage": {
"object": [
@@@ -877,6 +887,9 @@
"createObjectsClient": "function",
"mediaUrls": "function"
},
++ "openvibe-sdk/media-upload": {
++ "createUploadClient": "function"
++ },
"openvibe-sdk/meme": {
"createMemeClient": "function"
},
@@@ -1166,6 +1179,9 @@
"createTrustClient": "function",
"isNew": "function"
},
++ "openvibe-sdk/ui": {
++ "progress": "function"
++ },
"openvibe-sdk/usage": {
"createUsageReporter": "function",
"usageKey": "function",
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex dd9347a..2054a47 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -7,13 +7,13 @@ export const {
isActingSubjectId, satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION,
report, share, locker, auth, registry, identity,
trust, modules, events, realtime, media, cam,
-- community, homeFeed, bot, search, bench, meme,
-- vision, themes, design, redact, chat, chatApps,
-- ai, credentials, jobs, tools, projects, resources,
-- vip, quest, live, inventory, tips, blog,
-- reviews, wiki, coupons, deals, shoes, trade,
-- space, host, news, food, actor, run,
-- video, work, zone, play, download, openre,
-- geo, usage, commerce, telemetry,
++ mediaUpload, ui, community, homeFeed, bot, search,
++ bench, meme, vision, themes, design, redact,
++ chat, chatApps, ai, credentials, jobs, tools,
++ projects, resources, vip, quest, live, inventory,
++ tips, blog, reviews, wiki, coupons, deals,
++ shoes, trade, space, host, news, food,
++ actor, run, video, work, zone, play,
++ download, openre, geo, usage, commerce, telemetry,
} = cjs;
export default cjs;
ddiff --git a/esm/media-upload.mjs b/esm/media-upload.mjs
nnew file mode 100644
iindex 0000000..cb60d97
---- /dev/null
++++ b/esm/media-upload.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/media-upload.js. Do not edit.
++import cjs from '../src/media-upload.js';
++
++export const {
++ createUploadClient,
++} = cjs;
++export default cjs;
ddiff --git a/esm/ui.mjs b/esm/ui.mjs
nnew file mode 100644
iindex 0000000..370e16a
---- /dev/null
++++ b/esm/ui.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/ui.js. Do not edit.
++import cjs from '../src/ui.js';
++
++export const {
++ progress,
++} = cjs;
++export default cjs;
ddiff --git a/index.js b/index.js
iindex 423341c..0e8a15f 100644
---- a/index.js
++++ b/index.js
@@@ -21,6 +21,8 @@ module.exports = {
realtime: require('./src/realtime'),
media: require('./src/media'),
cam: require('./src/cam'),
++ mediaUpload: require('./src/media-upload'),
++ ui: require('./src/ui'),
community: require('./src/community'),
homeFeed: require('./src/home-feed'),
bot: require('./src/bot'),
ddiff --git a/package-lock.json b/package-lock.json
iindex 6f11423..f671046 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.119.0",
++ "version": "0.120.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.119.0",
++ "version": "0.120.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex c153ee2..7319ce5 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.119.0",
++ "version": "0.120.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, 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",
@@@ -84,6 +84,16 @@
"import": "./esm/media.mjs",
"default": "./src/media.js"
},
++ "./media-upload": {
++ "types": "./types/media-upload.d.ts",
++ "import": "./esm/media-upload.mjs",
++ "default": "./src/media-upload.js"
++ },
++ "./ui": {
++ "types": "./types/ui.d.ts",
++ "import": "./esm/ui.mjs",
++ "default": "./src/ui.js"
++ },
"./meme": {
"types": "./types/meme.d.ts",
"browser": "./src/meme.js",
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex fff9314..bd0aea3 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -34,6 +34,8 @@ const ENTRIES = {
'cam.mjs': '../src/cam/index.js',
'realtime.mjs': '../src/realtime.js',
'media.mjs': '../src/media.js',
++ 'media-upload.mjs': '../src/media-upload.js',
++ 'ui.mjs': '../src/ui.js',
'community.mjs': '../src/community.js',
'bot.mjs': '../src/bot.js',
'machines.mjs': '../src/machines.js',
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 4c2bf52..9c84823 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.119.0';
++const SDK_VERSION = '0.120.0';
module.exports = {
createClient,
ddiff --git a/src/media-upload.js b/src/media-upload.js
nnew file mode 100644
iindex 0000000..d947ea1
---- /dev/null
++++ b/src/media-upload.js
@@@ -0,0 +1,17 @@
++'use strict';
++const { createObjectsClient } = require('./media');
++const MIB = 1024 * 1024;
++
++/** Media's resumable multipart transport, configured for 8 MiB parts and three in flight. */
++function createUploadClient(config) {
++ const objects = createObjectsClient({ partSize: 8 * MIB, concurrency: 3, ...config });
++ return {
++ upload(file, options = {}) {
++ return objects.upload(file, { kind: 'file', filename: file && file.name, ...options });
++ },
++ resume(ref, file, options) { return objects.resume(ref, file, options); },
++ objects,
++ };
++}
++
++module.exports = { createUploadClient };
ddiff --git a/src/media.js b/src/media.js
iindex 68c0fb7..298cd65 100644
---- a/src/media.js
++++ b/src/media.js
@@@ -10,6 +10,7 @@
* delete, media.object.read to list and get. Media refuses a user JWT here: a browser uploads
* through its own app server, which names the user with X-OV-User-Id (the `actingUserId` option).
* Browser-safe code (FormData/Blob), but the credentials it needs belong on a server.
++ * The upload facade lives at openvibe-sdk/media-upload so this frozen module keeps its names.
*
* Sandbox app tokens land in the project's sandbox tenant (`<prj_…>-sandbox`, shown as `app_id`),
* whose files are never served publicly: Media answers them with `sandbox: true`, a signed,
ddiff --git a/src/objects.js b/src/objects.js
iindex 24bca25..3c8eb90 100644
---- a/src/objects.js
++++ b/src/objects.js
@@@ -225,6 +225,7 @@ function createObjectsClient({
}
const up = created.upload || {};
if (up.multipart) {
++ if (opts.onSession) await opts.onSession({ objectId: created.id, uploadId: up.multipart.upload_id });
return finishMultipart(created.id, up.multipart.upload_id, sessionIo(up.multipart, { objectId: created.id }), body, { signal, onProgress, contentHash, total });
}
await presigned(up.url, {
ddiff --git a/src/ui.js b/src/ui.js
nnew file mode 100644
iindex 0000000..34b5329
---- /dev/null
++++ b/src/ui.js
@@@ -0,0 +1,10 @@
++'use strict';
++
++/** Mount the shared OpenVibe progress UI after loading /shared/ov-upload.js and .css. */
++function progress(el, options) {
++ const upload = globalThis.OVUpload;
++ if (!upload || typeof upload.progress !== 'function') throw new Error('ov.ui.progress: load /shared/ov-upload.js first');
++ return upload.progress(el, options);
++}
++
++module.exports = { progress };
ddiff --git a/test/objects.test.js b/test/objects.test.js
iindex 36de2f9..0696a5b 100644
---- a/test/objects.test.js
++++ b/test/objects.test.js
@@@ -11,6 +11,7 @@ const crypto = require('node:crypto');
const { stubServer, send, problem, run } = require('./helpers');
const { createClient } = require('../src/core');
const { createObjectsClient } = require('../src/media');
++const { createUploadClient } = require('../src/media-upload');
const sha = (b) => crypto.createHash('sha256').update(b).digest('hex');
@@@ -188,10 +189,15 @@ run([
const m = await mockMedia();
const objects = createObjectsClient({ app: 'demo', baseUrl: m.srv.url, tokenClient: tokenClient(), multipartThreshold: 10000, partSize: 4096, concurrency: 3 });
const bytes = crypto.randomBytes(4096 * 4 + 100);
-- const obj = await objects.upload(new Blob([bytes], { type: 'video/webm' }), { kind: 'vod' });
++ let ref;
++ const obj = await objects.upload(new Blob([bytes], { type: 'video/webm' }), { kind: 'vod', onSession: (value) => {
++ ref = value;
++ assert.equal(m.srv.requests.filter((r) => r.method === 'PUT').length, 0, 'session is offered for persistence before bytes leave');
++ } });
const init = JSON.parse(m.srv.requests.find((r) => r.url === '/api/v2/demo/objects').body);
assert.deepEqual([init.multipart, init.part_size, init.mime_type, init.content_hash], [true, 4096, 'video/webm', sha(bytes)]);
const s = [...m.state.sessions.values()][0];
++ assert.deepEqual(ref, { objectId: obj.id, uploadId: s.upload_id });
assert.equal(s.parts_expected, 5);
const puts = m.srv.requests.filter((r) => r.method === 'PUT');
assert.equal(puts.length, 5);
@@@ -203,6 +209,18 @@ run([
await m.srv.close();
}],
++ ['the upload facade uses Media multipart and reports progress', async () => {
++ const m = await mockMedia();
++ const media = createUploadClient({ app: 'demo', baseUrl: m.srv.url, tokenClient: tokenClient(), multipartThreshold: 100, partSize: 1024 });
++ const bytes = crypto.randomBytes(2500);
++ const progress = [];
++ const obj = await media.upload(bytes, { onProgress: (p) => progress.push(p.uploadedBytes) });
++ assert.equal(obj.lifecycle_status, 'ready');
++ assert.equal(progress.at(-1), bytes.length);
++ assert.equal(JSON.parse(m.srv.requests.find((r) => r.url === '/api/v2/demo/objects').body).part_size, 1024);
++ await m.srv.close();
++ }],
++
['Media refusing a single part (413) sends it as multipart instead', async () => {
const m = await mockMedia({ singleMax: 1000 });
const objects = createObjectsClient({ app: 'demo', baseUrl: m.srv.url, tokenClient: tokenClient(), partSize: 1024 });
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex f9899a4..d059de2 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -72,7 +72,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, 96); // Adds the wallet client (0.119.0).
++ assert.equal(subpaths.length, 98); // Adds the upload client and the UI entry (0.120.0); the wallet came in 0.119.0.
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/test/ui.test.js b/test/ui.test.js
nnew file mode 100644
iindex 0000000..aebdd42
---- /dev/null
++++ b/test/ui.test.js
@@@ -0,0 +1,9 @@
++'use strict';
++const assert = require('node:assert/strict');
++const sdk = require('../index');
++
++assert.throws(() => sdk.ui.progress({}), /load \/shared\/ov-upload\.js first/);
++const expected = { update() {}, destroy() {} };
++globalThis.OVUpload = { progress: (el, options) => { assert.equal(el.id, 'p'); assert.equal(typeof options.onRetry, 'function'); return expected; } };
++try { assert.equal(sdk.ui.progress({ id: 'p' }, { onRetry() {} }), expected); }
++finally { delete globalThis.OVUpload; }
ddiff --git a/types/bundle.d.ts b/types/bundle.d.ts
iindex e07d433..ab2bc33 100644
---- a/types/bundle.d.ts
++++ b/types/bundle.d.ts
@@@ -5,6 +5,8 @@ import * as modules from './modules';
import * as realtime from './realtime';
import * as media from './media';
import * as cam from './cam';
++import * as mediaUpload from './media-upload';
++import * as ui from './ui';
import * as community from './community';
import * as homeFeed from './home-feed';
import * as bot from './bot';
@@@ -34,6 +36,8 @@ export * as modules from './modules';
export * as realtime from './realtime';
export * as media from './media';
export * as cam from './cam';
++export * as mediaUpload from './media-upload';
++export * as ui from './ui';
export * as community from './community';
export * as homeFeed from './home-feed';
export * as bot from './bot';
@@@ -57,6 +61,6 @@ export * as trust from './trust';
declare const sdk: typeof core & {
createWalletClient: typeof createWalletClient;
auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
-- media: typeof media; cam: typeof cam; community: typeof community; homeFeed: typeof homeFeed; bot: typeof bot; search: typeof search; bench: typeof bench; meme: typeof meme; vision: typeof vision; themes: typeof themes; design: typeof design; redact: typeof redact; chat: typeof chat; ai: typeof ai; credentials: typeof credentials; jobs: typeof jobs; tools: typeof tools; projects: typeof projects; geo: typeof geo; locker: typeof locker; trust: typeof trust;
++ media: typeof media; cam: typeof cam; mediaUpload: typeof mediaUpload; ui: typeof ui; community: typeof community; homeFeed: typeof homeFeed; bot: typeof bot; search: typeof search; bench: typeof bench; meme: typeof meme; vision: typeof vision; themes: typeof themes; design: typeof design; redact: typeof redact; chat: typeof chat; ai: typeof ai; credentials: typeof credentials; jobs: typeof jobs; tools: typeof tools; projects: typeof projects; geo: typeof geo; locker: typeof locker; trust: typeof trust;
};
export default sdk;
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex a8d27e8..9409bb6 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -11,6 +11,8 @@ export * as events from './events';
export * as realtime from './realtime';
export * as media from './media';
export * as cam from './cam';
++export * as mediaUpload from './media-upload';
++export * as ui from './ui';
export * as community from './community';
export * as homeFeed from './home-feed';
export * as bot from './bot';
ddiff --git a/types/media-upload.d.ts b/types/media-upload.d.ts
nnew file mode 100644
iindex 0000000..610ed37
---- /dev/null
++++ b/types/media-upload.d.ts
@@@ -0,0 +1,8 @@
++import type { createObjectsClient, UploadBody, ObjectUploadOptions, MediaObject, ObjectUploadRef, ObjectsClient } from './media';
++
++/** A ready-to-use multipart client with 8 MiB parts and three concurrent requests. */
++export declare function createUploadClient(opts: Parameters<typeof createObjectsClient>[0]): {
++ upload(file: UploadBody, options?: ObjectUploadOptions): Promise<MediaObject>;
++ resume(ref: ObjectUploadRef, file: UploadBody, options?: { signal?: AbortSignal; onProgress?: ObjectUploadOptions['onProgress']; contentHash?: string | false }): Promise<MediaObject>;
++ objects: ObjectsClient;
++};
ddiff --git a/types/media.d.ts b/types/media.d.ts
iindex f34696d..8e9f0f0 100644
---- a/types/media.d.ts
++++ b/types/media.d.ts
@@@ -124,6 +124,8 @@ export interface ObjectUploadOptions {
uploadTtl?: number;
signal?: AbortSignal;
onProgress?(p: { uploadedBytes: number; totalBytes: number; part?: number }): void;
++ /** Called as soon as Media creates a multipart session; persist this reference for reload resume. */
++ onSession?(ref: ObjectUploadRef): void | Promise<void>;
}
/** err.resume of an sdk.upload_incomplete error. */
@@@ -151,6 +153,7 @@ export interface ObjectsClient {
client: OpenVibeClient;
}
++
/**
* Media's object API v2. Credentials: tokenClient (createServiceTokenClient; media.object.upload and
* media.object.read for namespace `app`), apiKey, or a client carrying its own. Without baseUrl the Media
ddiff --git a/types/ui.d.ts b/types/ui.d.ts
nnew file mode 100644
iindex 0000000..4ac8713
---- /dev/null
++++ b/types/ui.d.ts
@@@ -0,0 +1,12 @@
++export type ProgressState = 'waiting' | 'sending' | 'paused' | 'processing' | 'done' | 'failed';
++export interface ProgressUpdate {
++ state?: ProgressState;
++ done?: number;
++ total?: number;
++ message?: string;
++}
++/** Requires the shared ov-upload.js and ov-upload.css assets on the page. */
++export declare function progress(el: HTMLElement, options?: { onRetry?(): void }): {
++ update(next: ProgressUpdate): void;
++ destroy(): void;
++};