openvibers/openvibe.media Commits
Document Media’s resumable multipart uploads and pin contracts v0.373.0 (#373)
Changes
@@@ -45,7 +45,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pgexport and deletion), OpenVibe.Live (`live.lineage.resolve`), OpenVibe.Events (the outbox relay andthe account and revocation subscriptions)- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host--- `openvibe-contracts` v0.338.0, `openvibe-sdk` v0.125.0 (tokens, events outbox, usage readings, per-actor limits),++- `openvibe-contracts` v0.373.0, `openvibe-sdk` v0.125.0 (tokens, events outbox, usage readings, per-actor limits),`openvibe-shared` v4.173.0, pinned by release tarball## Capabilities@@@ -129,7 +129,7 @@ server/storage.orphans.scan (monthly storage orphan report, service-wide, report only),storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)client-ip.js trust proxy = loopback; req.ip is the only client address--(openvibe-shared v4.173.0, openvibe-contracts v0.338.0, openvibe-sdk v0.125.0: pinned release tarballs, installed by npm)++(openvibe-shared v4.173.0, openvibe-contracts v0.373.0, openvibe-sdk v0.125.0: pinned release tarballs, installed by npm)scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)scripts/reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator toolsscripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposalsnnew file mode 100644@@@ -0,0 +1,36 @@++# Resumable uploads for OpenVibe apps++++Media's native object API already accepts resumable multipart uploads. Its contract is++`media.upload@1`; `/openapi.json` is built from `openvibe-contracts` and lists the create,++part, status, complete and abort operations. The canonical behavior and limits remain in++[the object model](object-model.md#multipart-uploads).++++1. Create an object with `POST /api/v2/:app/objects` and `multipart: true`, a known++ `size_bytes`, and `part_size: 8388608` (8 MiB). The caller needs a service or++ developer-app token with `media.object.upload`. Do not put an app key in browser code.++2. Save the returned `id` and `upload.multipart.upload_id` before sending bytes. The++ `part_url_template` carries a short-lived upload token. Send parts 1 through N to++ the template's URL. Three can run concurrently; failed parts can be retried with++ backoff. Parts may arrive out of order. Every part except the last is exactly the++ session's `part_size`.++3. After a disconnect or reload, fetch++ `GET /api/v2/:app/objects/:id/multipart/:uploadId`. Its `parts`, `missing`, and++ `received_bytes` fields tell the app what Media has stored. Compare the original++ file's size and hashes before resuming. An upload token or a suitable service++ credential can read the session.++4. Complete with `POST /api/v2/:app/objects/:id/multipart/:uploadId/complete`.++ `content_hash` and each part's SHA-256 can be supplied for verification. The++ answer is a ready object. A repeated completion of an already completed upload++ returns that same object. To discard an upload, `DELETE` its session URL.++++`openvibe-sdk/media-upload` exposes `createUploadClient({ app, tokenClient })`. Its++`upload(file, { onSession, onProgress })` uses 8 MiB parts and three concurrent++requests; `resume({ objectId, uploadId }, file, { onProgress })` sends only missing or++changed parts. The app is responsible for securely storing a file and its session++reference between page loads. A browser must receive a scoped upload path from its++own app server; Media's v2 create route does not accept a Network user JWT directly.++++Progress in a page is `OVUpload.progress(el)` from the pinned++`/shared/ov-upload.js` and `/shared/ov-upload.css`. The SDK also exposes++`ov.ui.progress(el)` as a wrapper. Keep transfer, processing, retry, and done as++distinct states; a completed transfer can still need server processing.@@@ -16,7 +16,7 @@"express": "^4.19.2","iovalkey": "^0.4.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0","pg": "^8.23.0",@@@ -2122,9 +2122,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.338.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",-- "integrity": "sha512-75FD6IxMsXoud3NFXKDs0/D0rhJtF2D+WL8M92ArIyabkUnM/sZvw6E6L97EqwQ3O7M/kB+uwsNSxGudbil+ng==",++ "version": "0.373.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",++ "integrity": "sha512-ddW6yG5deFxRgS/MQLgV6pyYx8vco49Nt83DrE+m9XAPwuHxFSeSZgvZ5CznK0cjul3CMmB5NaVXA6jdEbXAfQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -20,7 +20,7 @@"express": "^4.19.2","iovalkey": "^0.4.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0","pg": "^8.23.0",@@@ -6,6 +6,10 @@ const fs = require('fs');const path = require('path');const doc = require('openvibe-contracts').openapi.document('media');assert.ok(doc && doc.openapi && /^3\.1/.test(doc.openapi), 'the contracts carry an OpenAPI 3.1 document for media');++assert.ok(doc.paths['/api/v2/{app}/objects']?.post, 'upload creation is described');++assert.ok(doc.paths['/api/v2/{app}/objects/{id}/multipart/{uploadId}']?.get, 'upload resume status is described');++assert.ok(doc.paths['/api/v2/{app}/objects/{id}/multipart/{uploadId}/parts/{n}']?.put, 'part upload is described');++assert.ok(doc.paths['/api/v2/{app}/objects/{id}/multipart/{uploadId}/complete']?.post, 'completion is described');const src = fs.readFileSync(path.join(__dirname, '..', 'server/index.js'), 'utf8');assert.match(src, /app\.get\('\/openapi\.json'/, 'the route is mounted');assert.match(src, /openapi\.document\('media'\)/);
@@ -45,7 +45,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pg
@@ -45,7 +45,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pg
export and deletion), OpenVibe.Live (`live.lineage.resolve`), OpenVibe.Events (the outbox relay and
export and deletion), OpenVibe.Live (`live.lineage.resolve`), OpenVibe.Events (the outbox relay and
the account and revocation subscriptions)
the account and revocation subscriptions)
- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host
- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host
-- `openvibe-contracts` v0.338.0, `openvibe-sdk` v0.125.0 (tokens, events outbox, usage readings, per-actor limits),
+- `openvibe-contracts` v0.373.0, `openvibe-sdk` v0.125.0 (tokens, events outbox, usage readings, per-actor limits),
`openvibe-shared` v4.173.0, pinned by release tarball
`openvibe-shared` v4.173.0, pinned by release tarball
## Capabilities
## Capabilities
@@ -129,7 +129,7 @@ server/
@@ -129,7 +129,7 @@ server/
storage.orphans.scan (monthly storage orphan report, service-wide, report only),
storage.orphans.scan (monthly storage orphan report, service-wide, report only),
storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)
storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)
client-ip.js trust proxy = loopback; req.ip is the only client address
client-ip.js trust proxy = loopback; req.ip is the only client address
-(openvibe-shared v4.173.0, openvibe-contracts v0.338.0, openvibe-sdk v0.125.0: pinned release tarballs, installed by npm)
+(openvibe-shared v4.173.0, openvibe-contracts v0.373.0, openvibe-sdk v0.125.0: pinned release tarballs, installed by npm)
scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
scripts/reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator tools
scripts/reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator tools
scripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals
scripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+# Resumable uploads for OpenVibe apps
+
+Media's native object API already accepts resumable multipart uploads. Its contract is
+`media.upload@1`; `/openapi.json` is built from `openvibe-contracts` and lists the create,
+part, status, complete and abort operations. The canonical behavior and limits remain in
+[the object model](object-model.md#multipart-uploads).
+
+1. Create an object with `POST /api/v2/:app/objects` and `multipart: true`, a known
+ `size_bytes`, and `part_size: 8388608` (8 MiB). The caller needs a service or
+ developer-app token with `media.object.upload`. Do not put an app key in browser code.
+2. Save the returned `id` and `upload.multipart.upload_id` before sending bytes. The
+ `part_url_template` carries a short-lived upload token. Send parts 1 through N to
+ the template's URL. Three can run concurrently; failed parts can be retried with
+ backoff. Parts may arrive out of order. Every part except the last is exactly the
+ session's `part_size`.
+3. After a disconnect or reload, fetch
+ `GET /api/v2/:app/objects/:id/multipart/:uploadId`. Its `parts`, `missing`, and
+ `received_bytes` fields tell the app what Media has stored. Compare the original
+ file's size and hashes before resuming. An upload token or a suitable service
+ credential can read the session.
+4. Complete with `POST /api/v2/:app/objects/:id/multipart/:uploadId/complete`.
+ `content_hash` and each part's SHA-256 can be supplied for verification. The
+ answer is a ready object. A repeated completion of an already completed upload
+ returns that same object. To discard an upload, `DELETE` its session URL.
+
+`openvibe-sdk/media-upload` exposes `createUploadClient({ app, tokenClient })`. Its
+`upload(file, { onSession, onProgress })` uses 8 MiB parts and three concurrent
+requests; `resume({ objectId, uploadId }, file, { onProgress })` sends only missing or
+changed parts. The app is responsible for securely storing a file and its session
+reference between page loads. A browser must receive a scoped upload path from its
+own app server; Media's v2 create route does not accept a Network user JWT directly.
+
+Progress in a page is `OVUpload.progress(el)` from the pinned
+`/shared/ov-upload.js` and `/shared/ov-upload.css`. The SDK also exposes
+`ov.ui.progress(el)` as a wrapper. Keep transfer, processing, retry, and done as
+distinct states; a completed transfer can still need server processing.
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express": "^4.19.2",
"express": "^4.19.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -2122,9 +2122,9 @@
@@ -2122,9 +2122,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.338.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
- "integrity": "sha512-75FD6IxMsXoud3NFXKDs0/D0rhJtF2D+WL8M92ArIyabkUnM/sZvw6E6L97EqwQ3O7M/kB+uwsNSxGudbil+ng==",
+ "version": "0.373.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",
+ "integrity": "sha512-ddW6yG5deFxRgS/MQLgV6pyYx8vco49Nt83DrE+m9XAPwuHxFSeSZgvZ5CznK0cjul3CMmB5NaVXA6jdEbXAfQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"express": "^4.19.2",
"express": "^4.19.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -6,6 +6,10 @@ const fs = require('fs');
@@ -6,6 +6,10 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
const doc = require('openvibe-contracts').openapi.document('media');
const doc = require('openvibe-contracts').openapi.document('media');
assert.ok(doc && doc.openapi && /^3\.1/.test(doc.openapi), 'the contracts carry an OpenAPI 3.1 document for media');
assert.ok(doc && doc.openapi && /^3\.1/.test(doc.openapi), 'the contracts carry an OpenAPI 3.1 document for media');
+assert.ok(doc.paths['/api/v2/{app}/objects']?.post, 'upload creation is described');
+assert.ok(doc.paths['/api/v2/{app}/objects/{id}/multipart/{uploadId}']?.get, 'upload resume status is described');
+assert.ok(doc.paths['/api/v2/{app}/objects/{id}/multipart/{uploadId}/parts/{n}']?.put, 'part upload is described');
+assert.ok(doc.paths['/api/v2/{app}/objects/{id}/multipart/{uploadId}/complete']?.post, 'completion is described');
const src = fs.readFileSync(path.join(__dirname, '..', 'server/index.js'), 'utf8');
const src = fs.readFileSync(path.join(__dirname, '..', 'server/index.js'), 'utf8');
assert.match(src, /app\.get\('\/openapi\.json'/, 'the route is mounted');
assert.match(src, /app\.get\('\/openapi\.json'/, 'the route is mounted');
assert.match(src, /openapi\.document\('media'\)/);
assert.match(src, /openapi\.document\('media'\)/);