@devultur/convex 1.5.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -3
- package/dist/chunk-BDEO67OT.js +110 -0
- package/dist/component/http.js +2 -2
- package/dist/component/library.d.ts +2 -2
- package/dist/component/library.js +3 -3
- package/dist/component/lock.js +3 -3
- package/dist/component/mediaClient.js +1 -1
- package/dist/component/metadata.js +2 -2
- package/dist/component/public.js +9 -9
- package/dist/component/uploads.js +6 -6
- package/dist/component/workerErrors.d.ts +1 -1
- package/dist/component/workerErrors.js +1 -1
- package/dist/hooks-uqpBd5UF.d.ts +171 -0
- package/dist/index.d.ts +53 -16
- package/dist/index.js +1 -0
- package/dist/react/hooks.d.ts +6 -0
- package/dist/react/hooks.js +23 -0
- package/dist/react.d.ts +123 -277
- package/dist/react.js +784 -829
- package/docs/guides/object-lock.md +517 -0
- package/docs/guides/plugins.md +205 -0
- package/docs/guides/self-host.md +523 -0
- package/docs/guides/video-player.md +105 -0
- package/package.json +10 -5
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
# Self-host (bring your own R2)
|
|
2
|
+
|
|
3
|
+
Run the Devultur media Worker and SDKs against your own Cloudflare account and R2 bucket.
|
|
4
|
+
|
|
5
|
+
There are two ways to use Devultur on your own infrastructure:
|
|
6
|
+
|
|
7
|
+
- **Media Worker** (recommended): deploy `@devultur/self-host` on your Cloudflare account. It handles uploads,
|
|
8
|
+
delivery, transcode, captions and image transforms, and your backend talks to it with `@devultur/server`.
|
|
9
|
+
- **Direct R2**: point `@devultur/server` at your R2 bucket with S3 credentials and sign delivery yourself, with
|
|
10
|
+
no Worker in between.
|
|
11
|
+
|
|
12
|
+
## Direct R2
|
|
13
|
+
|
|
14
|
+
The server SDK presigns uploads straight to your bucket and signs delivery URLs:
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import { createMediaRouter, video, image, file } from "@devultur/server";
|
|
18
|
+
|
|
19
|
+
export const media = createMediaRouter({
|
|
20
|
+
storage: {
|
|
21
|
+
provider: "r2",
|
|
22
|
+
bucket: "my-media",
|
|
23
|
+
accountId: process.env.CF_ACCOUNT_ID!,
|
|
24
|
+
accessKeyId: process.env.R2_ACCESS_KEY!,
|
|
25
|
+
secretAccessKey: process.env.R2_SECRET_KEY!,
|
|
26
|
+
},
|
|
27
|
+
delivery: {
|
|
28
|
+
signSecret: process.env.DELIVERY_SIGN_SECRET!,
|
|
29
|
+
},
|
|
30
|
+
video: video({ maxSize: "2GB" }),
|
|
31
|
+
image: image({ maxSize: "10MB" }),
|
|
32
|
+
file: file({ maxSize: "500MB" }),
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
> [!NOTE]
|
|
37
|
+
> The Worker honors the `preset` you pass, so transcode resolution is yours to control. Cap it with
|
|
38
|
+
> `config.transcode.maxResolution` (see below).
|
|
39
|
+
|
|
40
|
+
## The media Worker
|
|
41
|
+
|
|
42
|
+
To run uploads, delivery, transcode and captions on your own Cloudflare account, deploy the
|
|
43
|
+
media Worker from `@devultur/self-host`:
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
import { createWorker } from "@devultur/self-host";
|
|
47
|
+
export { DownloadLink } from "@devultur/self-host";
|
|
48
|
+
|
|
49
|
+
export default createWorker();
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The Worker needs an R2 bucket bound as `MEDIA_BUCKET`, a second R2 bucket as `KEYS_BUCKET` (encryption keys), a KV
|
|
53
|
+
namespace as `MEDIA_KV` (pending uploads, transcode job state), the `DownloadLink` Durable Object (single-use download
|
|
54
|
+
links) and three secrets. The steps below set them up in order.
|
|
55
|
+
|
|
56
|
+
### Step by step
|
|
57
|
+
|
|
58
|
+
#### 1. Pick the account
|
|
59
|
+
|
|
60
|
+
If your login can see more than one Cloudflare account, non-interactive Wrangler commands fail with "More than one
|
|
61
|
+
account available but unable to select one in non-interactive mode". Pin the account before anything else, either in
|
|
62
|
+
`wrangler.toml`:
|
|
63
|
+
|
|
64
|
+
```toml
|
|
65
|
+
account_id = "<your-account-id>"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
or in the environment, for CI and scripts:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
export CLOUDFLARE_ACCOUNT_ID=<your-account-id>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
To check auth from a script, run `wrangler r2 bucket list`: it returns, and it proves both the login and R2 access.
|
|
75
|
+
Don't use `wrangler whoami` for that check: some versions print the account and then hang.
|
|
76
|
+
|
|
77
|
+
#### 2. Create the buckets
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
wrangler r2 bucket create media
|
|
81
|
+
wrangler r2 bucket create media-keys
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`KEYS_BUCKET` holds the key of every file encrypted at rest ([L4 encryption](https://github.com/devultur/devultur/blob/main/docs/guides/security.md#l4--encryption)). With
|
|
85
|
+
`security.encrypt` on, the Worker fails closed without it: a private upload, transcode or captions request answers
|
|
86
|
+
`503 keys_bucket_missing` and nothing is stored, instead of storing the file unencrypted. Keep it a separate
|
|
87
|
+
bucket, never public, and [back it up](#backing-up-keys_bucket): losing a key loses its file for good.
|
|
88
|
+
|
|
89
|
+
#### 3. Create the KV namespace
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
wrangler kv namespace create media-state
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
It prints a config snippet with the new namespace's `id`. Paste that id into the `MEDIA_KV` binding of
|
|
96
|
+
`wrangler.toml`. Running `create` again doesn't give you the id of a namespace that already exists: find it with
|
|
97
|
+
`wrangler kv namespace list`, which prints every namespace of the account with its `title` and `id`.
|
|
98
|
+
|
|
99
|
+
#### 4. Write `wrangler.toml`
|
|
100
|
+
|
|
101
|
+
Bind both buckets, the KV namespace and the `DownloadLink` Durable Object:
|
|
102
|
+
|
|
103
|
+
```toml
|
|
104
|
+
name = "media"
|
|
105
|
+
main = "src/index.ts"
|
|
106
|
+
compatibility_date = "2026-10-01"
|
|
107
|
+
account_id = "<your-account-id>"
|
|
108
|
+
|
|
109
|
+
[[r2_buckets]]
|
|
110
|
+
binding = "MEDIA_BUCKET"
|
|
111
|
+
bucket_name = "media"
|
|
112
|
+
|
|
113
|
+
# Per-object key material for encryption at rest. Required when `security.encrypt` is on.
|
|
114
|
+
[[r2_buckets]]
|
|
115
|
+
binding = "KEYS_BUCKET"
|
|
116
|
+
bucket_name = "media-keys"
|
|
117
|
+
|
|
118
|
+
[[kv_namespaces]]
|
|
119
|
+
binding = "MEDIA_KV"
|
|
120
|
+
id = "<kv-namespace-id>"
|
|
121
|
+
|
|
122
|
+
[[durable_objects.bindings]]
|
|
123
|
+
name = "DOWNLOAD_LINKS"
|
|
124
|
+
class_name = "DownloadLink"
|
|
125
|
+
|
|
126
|
+
[[migrations]]
|
|
127
|
+
tag = "v1"
|
|
128
|
+
new_sqlite_classes = ["DownloadLink"]
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Without the `DOWNLOAD_LINKS` binding, `POST /v1/token/download` answers `501`. For staging and production in one
|
|
132
|
+
file, see [Per-environment config](#per-environment-config).
|
|
133
|
+
|
|
134
|
+
#### 5. Set the secrets
|
|
135
|
+
|
|
136
|
+
The Worker requires all three secrets, even if you never transcode a video. Without them it fails closed: it doesn't
|
|
137
|
+
fall back to open access.
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
wrangler secret put DELIVERY_SIGN_SECRET # openssl rand -base64 32
|
|
141
|
+
wrangler secret put WORKER_AUTH_SECRET # openssl rand -base64 32
|
|
142
|
+
wrangler secret put TRANSCODE_WEBHOOK_SECRET # openssl rand -base64 32
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
- **`DELIVERY_SIGN_SECRET`** signs viewer tokens, download links and transcode job tickets. Without it,
|
|
146
|
+
issuing or verifying them fails with `500`.
|
|
147
|
+
- **`WORKER_AUTH_SECRET`** is your backend's credential. Every control route requires
|
|
148
|
+
`Authorization: Bearer <WORKER_AUTH_SECRET>`. Without the secret, those routes answer
|
|
149
|
+
`503 worker_auth_secret_missing`. With a missing or wrong bearer, they answer `401`. The transcode
|
|
150
|
+
runner also uploads its renditions with this secret, and a job's `callbackUrl` receives it as its bearer (the
|
|
151
|
+
Convex component checks it against `DEVULTUR_SECRET`).
|
|
152
|
+
- **`TRANSCODE_WEBHOOK_SECRET`** signs the transcode callbacks (see [Transcode runners](#transcode-runners)).
|
|
153
|
+
Without it, the Worker answers `503` to every callback, so no transcode ever completes: the first video upload
|
|
154
|
+
would stay in progress for good. Set it even if you don't plan to transcode.
|
|
155
|
+
|
|
156
|
+
For captions, also set `GROQ_API_KEY` (transcription) and `MISTRAL_API_KEY` (translation) as secrets. Secrets are
|
|
157
|
+
per environment: with [environments](#per-environment-config), add `--env <name>` to each `wrangler secret put`.
|
|
158
|
+
|
|
159
|
+
#### 6. Deploy
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
wrangler deploy # with environments: wrangler deploy --env production
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
#### 7. Verify
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
curl https://media.example.com/health
|
|
169
|
+
curl https://media.example.com/v1/assets -H "Authorization: Bearer $WORKER_AUTH_SECRET"
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`/health` is open. `GET /v1/assets` takes the Worker secret: it answers `401` with a wrong bearer, and
|
|
173
|
+
`503 worker_auth_secret_missing` if the secret didn't reach this environment. Then upload one file from your backend
|
|
174
|
+
and fetch it back.
|
|
175
|
+
|
|
176
|
+
### Per-environment config
|
|
177
|
+
|
|
178
|
+
Under `[env.<name>]`, Wrangler doesn't inherit `r2_buckets`, `kv_namespaces` or `durable_objects` from the top
|
|
179
|
+
level: repeat every binding in every environment, `KEYS_BUCKET` included. `migrations` stays top-level. Give each
|
|
180
|
+
environment its own buckets, KV namespace and secrets:
|
|
181
|
+
|
|
182
|
+
```toml
|
|
183
|
+
name = "media"
|
|
184
|
+
main = "src/index.ts"
|
|
185
|
+
compatibility_date = "2026-10-01"
|
|
186
|
+
account_id = "<your-account-id>"
|
|
187
|
+
|
|
188
|
+
[[migrations]]
|
|
189
|
+
tag = "v1"
|
|
190
|
+
new_sqlite_classes = ["DownloadLink"]
|
|
191
|
+
|
|
192
|
+
# Staging
|
|
193
|
+
[env.staging]
|
|
194
|
+
name = "media-staging"
|
|
195
|
+
|
|
196
|
+
[[env.staging.r2_buckets]]
|
|
197
|
+
binding = "MEDIA_BUCKET"
|
|
198
|
+
bucket_name = "media-staging"
|
|
199
|
+
|
|
200
|
+
[[env.staging.r2_buckets]]
|
|
201
|
+
binding = "KEYS_BUCKET"
|
|
202
|
+
bucket_name = "media-keys-staging"
|
|
203
|
+
|
|
204
|
+
[[env.staging.kv_namespaces]]
|
|
205
|
+
binding = "MEDIA_KV"
|
|
206
|
+
id = "<staging-kv-namespace-id>"
|
|
207
|
+
|
|
208
|
+
[[env.staging.durable_objects.bindings]]
|
|
209
|
+
name = "DOWNLOAD_LINKS"
|
|
210
|
+
class_name = "DownloadLink"
|
|
211
|
+
|
|
212
|
+
# Production
|
|
213
|
+
[env.production]
|
|
214
|
+
name = "media-production"
|
|
215
|
+
|
|
216
|
+
[[env.production.r2_buckets]]
|
|
217
|
+
binding = "MEDIA_BUCKET"
|
|
218
|
+
bucket_name = "media-production"
|
|
219
|
+
|
|
220
|
+
[[env.production.r2_buckets]]
|
|
221
|
+
binding = "KEYS_BUCKET"
|
|
222
|
+
bucket_name = "media-keys-production"
|
|
223
|
+
|
|
224
|
+
[[env.production.kv_namespaces]]
|
|
225
|
+
binding = "MEDIA_KV"
|
|
226
|
+
id = "<production-kv-namespace-id>"
|
|
227
|
+
|
|
228
|
+
[[env.production.durable_objects.bindings]]
|
|
229
|
+
name = "DOWNLOAD_LINKS"
|
|
230
|
+
class_name = "DownloadLink"
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`vars` aren't inherited either: repeat `[env.<name>.vars]` (for example `TRANSCODE_RUNNER`) in each one. Then set
|
|
234
|
+
the secrets and deploy per environment:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
wrangler secret put WORKER_AUTH_SECRET --env staging
|
|
238
|
+
wrangler deploy --env staging
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
[Wrangler environments](https://developers.cloudflare.com/workers/wrangler/environments/) lists what is and isn't
|
|
242
|
+
inherited.
|
|
243
|
+
|
|
244
|
+
### Backing up `KEYS_BUCKET`
|
|
245
|
+
|
|
246
|
+
R2 has no built-in backup, and losing a key loses its file for good: the encrypted bytes in `MEDIA_BUCKET` can't be
|
|
247
|
+
read without it. Copy `KEYS_BUCKET` to a bucket of its own, ideally in another account or with another provider:
|
|
248
|
+
|
|
249
|
+
- **A cron Worker** bound to `KEYS_BUCKET` and the backup bucket, which lists the keys bucket and copies each object
|
|
250
|
+
the backup doesn't have yet. A key is written once and never changes, so copying the new ones is enough. Copy each
|
|
251
|
+
object's `customMetadata` along with its body.
|
|
252
|
+
- **rclone** with [R2's S3 API](https://developers.cloudflare.com/r2/examples/rclone/), from a machine or a CI job on
|
|
253
|
+
a schedule.
|
|
254
|
+
|
|
255
|
+
Each key lives at `{storageKey}/{keyId}`: the full storage key of its file plus a random id. To restore, copy the
|
|
256
|
+
objects back to the same keys, with their metadata. The Worker finds a file's key by that path, so a key restored
|
|
257
|
+
under another path is as good as lost. Give the backup the same access rules as the keys bucket: whoever holds both
|
|
258
|
+
the keys and the media can read your encrypted files.
|
|
259
|
+
|
|
260
|
+
### R2 bucket lock (defense in depth)
|
|
261
|
+
|
|
262
|
+
[Retention and holds](object-lock.md) are enforced by the Worker. An
|
|
263
|
+
[R2 bucket lock rule](https://developers.cloudflare.com/r2/buckets/bucket-locks/) adds a storage-level layer: R2
|
|
264
|
+
itself refuses overwrites and deletes of matching objects, also from the dashboard, `wrangler r2` or the S3 API.
|
|
265
|
+
Lock the same prefix in both buckets, so a file and its key are kept together:
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
# {root} is the Worker's storage prefix ("" if you don't set one)
|
|
269
|
+
wrangler r2 bucket lock add media records-retention "{root}private/asset/records/" --retention-days 3650
|
|
270
|
+
wrangler r2 bucket lock add media-keys records-retention "{root}private/asset/records/" --retention-days 3650
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
An asset is stored at `{root}{visibility}/asset/{path}` in `MEDIA_BUCKET`, and its key at
|
|
274
|
+
`{root}{visibility}/asset/{path}/{keyId}` in `KEYS_BUCKET`, so one prefix covers both. Use `--retention-days`,
|
|
275
|
+
`--retention-date` or `--retention-indefinite` to match the longest retention you set through the Worker.
|
|
276
|
+
|
|
277
|
+
> [!WARNING]
|
|
278
|
+
> Never lock `__lock/` or `__lock-audit/`, or a prefix that contains them, such as `""` or `{root}` alone. The Worker
|
|
279
|
+
> rewrites those records on every lock change, and a bucket lock on them makes lock changes and holds fail.
|
|
280
|
+
|
|
281
|
+
What changes once the rule is on:
|
|
282
|
+
|
|
283
|
+
- Nothing under the prefix can be deleted or replaced until its R2 retention ends, whatever the Worker's own lock
|
|
284
|
+
says. That includes image variants (`{key}/__variants/…`) and the cleanup of `maxFileLifetimeSeconds`.
|
|
285
|
+
- **Racing duplicate uploads.** When two create-only uploads race for the same asset path, the loser deletes the
|
|
286
|
+
key it made for its failed write. Under the lock R2 refuses that delete, so the loser gets a `500` instead of
|
|
287
|
+
`409 already_exists`. Retry it: the retry sees the existing file and answers `409 already_exists`.
|
|
288
|
+
- **Don't trust `wrangler r2 object delete` to test the rule.** On a locked object it prints "Delete complete." and
|
|
289
|
+
exits `0`, but the object stays. Check for the object afterwards instead:
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
wrangler r2 object delete "media/private/asset/records/test.pdf" --remote
|
|
293
|
+
wrangler r2 object get "media/private/asset/records/test.pdf" --remote --file check.pdf # still there
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Anyone who can edit the bucket's configuration can still remove the rule, so it raises the bar rather than closing
|
|
297
|
+
it.
|
|
298
|
+
|
|
299
|
+
### Calling the Worker
|
|
300
|
+
|
|
301
|
+
Your backend calls the Worker with the server SDK. Pass the Worker URL as `baseUrl` and the
|
|
302
|
+
secret as `secret`. Never ship the secret to the browser.
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
import { createMediaRouter } from "@devultur/server";
|
|
306
|
+
|
|
307
|
+
export const media = createMediaRouter({
|
|
308
|
+
baseUrl: "https://media.example.com",
|
|
309
|
+
secret: process.env.WORKER_AUTH_SECRET!,
|
|
310
|
+
});
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
| Route | Access |
|
|
314
|
+
| --- | --- |
|
|
315
|
+
| `POST /v1/upload` | Worker secret, or an upload token from `POST /v1/token/upload` (only inside its prefix) |
|
|
316
|
+
| `PUT`, `POST`, `DELETE /v1/upload/<key>?u=<id>` | The URL that `POST /v1/upload` returned is the credential: its random `u` id names the upload, and a missing or wrong id gets `404`. Without that URL, only the Worker secret can write. |
|
|
317
|
+
| `DELETE /v1/media/…`, `GET /v1/assets` | Worker secret |
|
|
318
|
+
| `/v1/transcode`, `/v1/captions` | Worker secret |
|
|
319
|
+
| `POST /v1/token`, `/v1/token/upload`, `/v1/token/download` | Worker secret |
|
|
320
|
+
| `GET`/`PUT /v1/lock/<key>`, `POST /v1/lock/hold` | Worker secret ([Retention and holds](object-lock.md)) |
|
|
321
|
+
| `GET`/`HEAD /v1/media/…` | Public media is open. Private media needs a viewer token from `POST /v1/token`, or the Worker secret. |
|
|
322
|
+
| `GET /v1/download/<id>` | The link id is the credential (single use) |
|
|
323
|
+
| `POST /v1/webhooks/transcode` | `TRANSCODE_WEBHOOK_SECRET` |
|
|
324
|
+
| `GET /health`, `POST /v1/metrics/playback` | Open |
|
|
325
|
+
|
|
326
|
+
The browser never talks to a control route. It uploads to the URL your backend got from
|
|
327
|
+
`POST /v1/upload`, and plays private media with a viewer token your backend issued.
|
|
328
|
+
|
|
329
|
+
> [!WARNING]
|
|
330
|
+
> The Worker secret check is the `workerSecretAuth()` plugin. To authenticate with your own credentials, replace it
|
|
331
|
+
> with an auth plugin of yours (see [Plugins](plugins.md)): `createWorker()` refuses to start without one.
|
|
332
|
+
|
|
333
|
+
## Rules
|
|
334
|
+
|
|
335
|
+
`createWorker()` runs the [default plugins](plugins.md#default-plugins). Pass the rules every request follows to
|
|
336
|
+
`defaultPlugins(config)`:
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
import { createWorker, defaultPlugins } from "@devultur/self-host";
|
|
340
|
+
|
|
341
|
+
export default createWorker({
|
|
342
|
+
plugins: defaultPlugins({
|
|
343
|
+
security: { sessionBinding: true, ipBinding: false, encrypt: true }, // encrypt needs KEYS_BUCKET
|
|
344
|
+
uploadRules: { image: { maxSizeBytes: 10_000_000, accept: ["image/*"] } },
|
|
345
|
+
captionsLocales: ["en", "es"],
|
|
346
|
+
transcode: { maxResolution: "1080p", deleteOriginalOnSuccess: true },
|
|
347
|
+
}),
|
|
348
|
+
});
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
| `config` field | What it does |
|
|
352
|
+
| --- | --- |
|
|
353
|
+
| `security` | Default security policy: session and IP binding, encryption at rest |
|
|
354
|
+
| `uploadRules` | Per media type (`video`, `image`, `file`): `maxSizeBytes`, `accept` and default `visibility` |
|
|
355
|
+
| `captionsLocales` | Locales `/v1/captions` accepts (unset: any) |
|
|
356
|
+
| `cacheRules` | `Cache-Control` per prefix of the `asset/` namespace |
|
|
357
|
+
| `originalStorageClass` | R2 storage class of an uploaded video original (`Standard` or `InfrequentAccess`) |
|
|
358
|
+
| `maxFileLifetimeSeconds` | Longest lifetime of an uploaded file, in seconds: the default TTL and a ceiling (a shorter `expiresIn` still wins). For a demo or scratch Worker whose uploads clean themselves up. A [locked](object-lock.md#retention-vs-expiry) file still expires (viewers get `410`) but isn't deleted until its lock ends. |
|
|
359
|
+
| `uploadMetadata` | String map added to the R2 `customMetadata` of every uploaded object (for example which app uploaded it). Keys the Worker owns (`security`, `enc`, `expiresAt`, `derivedFrom`, `lock`, `sha256*`) are dropped. |
|
|
360
|
+
| `lock` | Default lock of an uploaded file: retention and hold (see [Retention and holds](object-lock.md)). An upload or its upload token overrides it. |
|
|
361
|
+
| `contentSniffing` | What an upload whose bytes contradict its declared type gets: `scriptable` (default: `415 content_type_mismatch` only for markup such as HTML or SVG under an inert type), `reject` (every mismatch), `octet-stream` or `off`. Every mismatch is logged as `[content-sniff] {…}`. See [Content verification](https://github.com/devultur/devultur/blob/main/docs/guides/security.md#content-verification). |
|
|
362
|
+
| `transcode.presets` | Named presets and the default one, used when a request names no preset |
|
|
363
|
+
| `transcode.maxResolution` | Highest rung of the HLS ladder (`720p`, `1080p`, `4k`) |
|
|
364
|
+
| `transcode.deleteOriginalOnSuccess` | Delete the original once a transcode completes. It's never deleted after a failure, nor while it's [locked](object-lock.md#transcoding-a-locked-video). |
|
|
365
|
+
| `transcode.outputBaseUrl` | Base of the output URLs in your webhook. Unset: the origin of the transcode request. |
|
|
366
|
+
|
|
367
|
+
A plugin can narrow these rules per request, for example to give each of your apps its own storage prefix,
|
|
368
|
+
upload rules and credentials. [Plugins](plugins.md) covers the request state, the hooks before a credential is
|
|
369
|
+
issued or a download link is redeemed, and three example plugins: JWT auth, a usage log and rate limiting.
|
|
370
|
+
|
|
371
|
+
## Transcode runners
|
|
372
|
+
|
|
373
|
+
The self-host media Worker doesn't run FFmpeg itself. It generates a shell script for each
|
|
374
|
+
video and hands it to a **runner**. The runner starts that script inside the transcode image
|
|
375
|
+
(`infra/transcode/Dockerfile`, Alpine + FFmpeg + curl). The script downloads the raw video from
|
|
376
|
+
your Worker, encodes the HLS ladder, uploads the renditions back, and reports progress through
|
|
377
|
+
signed webhooks to `/v1/webhooks/transcode`. Every runner uses the same script, and job status
|
|
378
|
+
always comes from those webhooks, never from the provider.
|
|
379
|
+
|
|
380
|
+
Pick the runner with the `TRANSCODE_RUNNER` var:
|
|
381
|
+
|
|
382
|
+
| Runner | `TRANSCODE_RUNNER` | Best for | Needs |
|
|
383
|
+
| --- | --- | --- | --- |
|
|
384
|
+
| Cloudflare Containers | `cloudflare` (default when unset) | **Recommended.** Same deploy and account as the Worker | Workers Paid plan, Docker where you run `wrangler deploy` |
|
|
385
|
+
| Fly Machines | `fly` | An existing Fly setup | A Fly app, an API token, a pushed image |
|
|
386
|
+
| HTTP | `http` | A VPS or your own queue | A host running the reference runner behind HTTPS |
|
|
387
|
+
|
|
388
|
+
Set `TRANSCODE_WEBHOOK_SECRET` ([step 5](#5-set-the-secrets)) whatever runner you pick. The
|
|
389
|
+
script sends it on every callback, and the Worker rejects callbacks that don't carry it. If the
|
|
390
|
+
secret is unset, the Worker answers `503` to every callback.
|
|
391
|
+
|
|
392
|
+
### Cloudflare Containers (recommended)
|
|
393
|
+
|
|
394
|
+
Each video gets its own Durable Object, and that Durable Object starts one container with
|
|
395
|
+
the transcode image. Wrangler builds and pushes the image on `wrangler deploy`.
|
|
396
|
+
|
|
397
|
+
1. Re-export the container class from your Worker entry:
|
|
398
|
+
|
|
399
|
+
```typescript
|
|
400
|
+
import { createWorker } from "@devultur/self-host";
|
|
401
|
+
export { TranscodeContainer } from "@devultur/self-host/transcode-container";
|
|
402
|
+
|
|
403
|
+
export default createWorker();
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
2. Add the container, the binding and the var to `wrangler.toml` (full snippet in
|
|
407
|
+
`infra/transcode/wrangler.containers.example.toml`):
|
|
408
|
+
|
|
409
|
+
```toml
|
|
410
|
+
[vars]
|
|
411
|
+
TRANSCODE_RUNNER = "cloudflare"
|
|
412
|
+
# CF_TRANSCODE_INSTANCE = "standard-4" # default: 4 vCPU, 12 GiB, 20 GB disk
|
|
413
|
+
|
|
414
|
+
[[containers]]
|
|
415
|
+
class_name = "TranscodeContainer"
|
|
416
|
+
scheduling_policy = "durable_object"
|
|
417
|
+
|
|
418
|
+
[containers.images.transcode]
|
|
419
|
+
dockerfile = "../../infra/transcode/Dockerfile"
|
|
420
|
+
|
|
421
|
+
[[durable_objects.bindings]]
|
|
422
|
+
name = "TRANSCODE_CONTAINER"
|
|
423
|
+
class_name = "TranscodeContainer"
|
|
424
|
+
|
|
425
|
+
[exports.TranscodeContainer]
|
|
426
|
+
type = "durable-object"
|
|
427
|
+
storage = "sqlite"
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
3. Run `wrangler deploy` with a recent Wrangler (4.150 accepts `scheduling_policy = "durable_object"`, `images` and `exports`; 4.103 rejects them).
|
|
431
|
+
|
|
432
|
+
> [!WARNING]
|
|
433
|
+
> The instance disk has to hold the raw upload plus every rendition. On `standard-4` that's
|
|
434
|
+
> 20 GB, so a very long 4K source may not fit. If a container dies without running the
|
|
435
|
+
> script's failure trap (out of memory, host restart), its Durable Object reports
|
|
436
|
+
> `transcode.failed` for it.
|
|
437
|
+
|
|
438
|
+
### Fly Machines
|
|
439
|
+
|
|
440
|
+
Each job runs on a fresh Fly Machine that destroys itself when the job ends. Creating a
|
|
441
|
+
machine is retried on 429/5xx with exponential backoff, because the Machines API allows 1
|
|
442
|
+
create per second.
|
|
443
|
+
|
|
444
|
+
```bash
|
|
445
|
+
fly apps create my-transcode
|
|
446
|
+
cd infra/transcode && fly deploy --ha=false # note the image tag it prints
|
|
447
|
+
wrangler secret put FLY_API_TOKEN
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
| Var | Default | |
|
|
451
|
+
| --- | --- | --- |
|
|
452
|
+
| `FLY_APP_NAME` | — | Fly app that runs the machines |
|
|
453
|
+
| `FLY_TRANSCODE_IMAGE` | — | `registry.fly.io/<app>:<tag>` |
|
|
454
|
+
| `FLY_API_TOKEN` | — | Secret |
|
|
455
|
+
| `FLY_TRANSCODE_CPU_KIND` | `performance` | Avoid `shared`: FFmpeg gets throttled to its 6.25% CPU baseline |
|
|
456
|
+
| `FLY_TRANSCODE_CPUS` | `4` | |
|
|
457
|
+
| `FLY_TRANSCODE_MEMORY_MB` | `8192` | |
|
|
458
|
+
| `FLY_REGION` | — | Region of the machines (`iad`, `fra`, …). Unset: Fly picks one. |
|
|
459
|
+
|
|
460
|
+
### HTTP (VPS or your own queue)
|
|
461
|
+
|
|
462
|
+
The Worker POSTs each job to `TRANSCODE_RUNNER_URL` with `Authorization: Bearer <TRANSCODE_RUNNER_SECRET>`:
|
|
463
|
+
|
|
464
|
+
```json
|
|
465
|
+
{
|
|
466
|
+
"version": 1,
|
|
467
|
+
"videoId": "abc123",
|
|
468
|
+
"command": ["sh", "-c", "echo \"$TRANSCODE_SCRIPT_B64\" | base64 -d | sh"],
|
|
469
|
+
"env": { "TRANSCODE_SCRIPT_B64": "…", "WORKER_URL": "…", "CALLBACK_URL": "…" }
|
|
470
|
+
}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Your runner runs `command` with `env` in the transcode image and answers `2xx`, optionally
|
|
474
|
+
with `{ "jobId": "…" }`. The Worker retries 429/5xx responses with backoff. The reference
|
|
475
|
+
runner `infra/transcode/http-runner/server.ts` (Node 22.18+, no dependencies) queues jobs
|
|
476
|
+
and runs each one with `docker run`:
|
|
477
|
+
|
|
478
|
+
```bash
|
|
479
|
+
docker build -t devultur-ffmpeg infra/transcode
|
|
480
|
+
RUNNER_SECRET=$(openssl rand -base64 32) MAX_CONCURRENT_JOBS=2 \
|
|
481
|
+
node infra/transcode/http-runner/server.ts
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
Then set `TRANSCODE_RUNNER = "http"` and `TRANSCODE_RUNNER_URL = "https://runner.example.com/jobs"`
|
|
485
|
+
on the Worker, plus `wrangler secret put TRANSCODE_RUNNER_SECRET`. Optionally set
|
|
486
|
+
`TRANSCODE_RUNNER_CPUS` to the cores per job, which feeds the encode measurement.
|
|
487
|
+
|
|
488
|
+
> [!CAUTION]
|
|
489
|
+
> The job `env` carries the runner's upload credential and the callback secret. Only expose the
|
|
490
|
+
> runner over HTTPS, for example behind Caddy or a Cloudflare Tunnel.
|
|
491
|
+
|
|
492
|
+
### Measuring encode cost
|
|
493
|
+
|
|
494
|
+
Every `transcode.complete` the runner sends carries an `encode` measurement, for you as the operator: `seconds`
|
|
495
|
+
(FFmpeg wall-clock time), `cpus`, `runner` and `topRung` (the highest rendition), plus the per-title `complexity`,
|
|
496
|
+
`probeSeconds` and `params`. The Worker logs it as `[transcode-metrics] {…"vcpuSecondsPerVideoMinute":…}` (vCPU
|
|
497
|
+
seconds per minute of video, to size and compare runners), and a plugin can read it with `parseEncodeMetrics`.
|
|
498
|
+
Your app's webhook doesn't get it.
|
|
499
|
+
|
|
500
|
+
For presets, templates and inline specs, each entry of `renditions` in the webhook carries the output's real
|
|
501
|
+
`width` and `height` in pixels (an HLS ladder by its top rung), measured on the encoded file.
|
|
502
|
+
|
|
503
|
+
## Image transforms
|
|
504
|
+
|
|
505
|
+
Image transforms (`?w=`, `?h=`, `?fit=`, `?format=`, `?q=`) run inside the media Worker with WebAssembly
|
|
506
|
+
codecs. Each unique transform is computed once and stored in your bucket next to the original
|
|
507
|
+
(`{key}/__variants/…`), so later requests are served like any other object, with `Range` support and
|
|
508
|
+
the edge cache.
|
|
509
|
+
|
|
510
|
+
- **Large originals.** The Worker decodes images up to 4 megapixels (`IMAGE_TRANSFORMS_MAX_MEGAPIXELS`).
|
|
511
|
+
For a larger original, the first transform asks Cloudflare Images for one downscaled master and stores
|
|
512
|
+
it next to the original. Every later variant is built from that master in the Worker.
|
|
513
|
+
- **Cloudflare Images fallback.** GIFs, animated images, sizes that need more pixels than the master
|
|
514
|
+
has, and any codec failure go to Cloudflare Images for that request. Image Transformations must be
|
|
515
|
+
enabled on the zone that serves the Worker.
|
|
516
|
+
- **`fit=fill`** stretches to the exact `w`×`h`, ignoring the aspect ratio.
|
|
517
|
+
- **`format=auto`** is negotiated from the `Accept` header: AVIF for outputs up to about 1 megapixel, then
|
|
518
|
+
WebP, then JPEG (PNG for PNG originals). Responses carry `Vary: Accept`.
|
|
519
|
+
- **Private and encrypted images.** Variants of a private image stay private. If the original is
|
|
520
|
+
encrypted at rest, its variants are encrypted too, each with its own key.
|
|
521
|
+
- **Deleting** an original also deletes its variants and their keys.
|
|
522
|
+
|
|
523
|
+
Set `IMAGE_TRANSFORMS = "cloudflare"` to send every transform to Cloudflare Images instead.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Video player
|
|
2
|
+
|
|
3
|
+
Stream adaptive HLS with captions, quality selection, and keyboard shortcuts using the VideoPlayer component.
|
|
4
|
+
|
|
5
|
+
`VideoPlayer` streams HLS with adaptive bitrate, a quality picker, captions, and
|
|
6
|
+
keyboard shortcuts out of the box.
|
|
7
|
+
|
|
8
|
+
```tsx
|
|
9
|
+
import { VideoPlayer } from "@devultur/react";
|
|
10
|
+
|
|
11
|
+
<VideoPlayer
|
|
12
|
+
src={video.playlistUrl}
|
|
13
|
+
token={viewerToken}
|
|
14
|
+
captions={[
|
|
15
|
+
{ locale: "es-CO", label: "Español", src: video.captions.esCO },
|
|
16
|
+
{ locale: "en", label: "English", src: video.captions.en },
|
|
17
|
+
]}
|
|
18
|
+
defaultCaption="es-CO"
|
|
19
|
+
initialTime={progress.lastPosition}
|
|
20
|
+
onProgress={(time) => saveProgress(video.id, time)}
|
|
21
|
+
onEnded={() => markComplete(video.id)}
|
|
22
|
+
/>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Playback metrics
|
|
26
|
+
|
|
27
|
+
> [!IMPORTANT]
|
|
28
|
+
> Playback metrics are **on by default**. They're anonymous and set no cookies. To turn them off, pass
|
|
29
|
+
> `metrics={false}`:
|
|
30
|
+
>
|
|
31
|
+
> ```tsx
|
|
32
|
+
> <VideoPlayer src={video.playlistUrl} metrics={false} />
|
|
33
|
+
> ```
|
|
34
|
+
>
|
|
35
|
+
> `useVideoPlayer({ metrics: false })` does the same for a custom player. With `metrics={false}` the player adds no
|
|
36
|
+
> listeners and sends nothing.
|
|
37
|
+
|
|
38
|
+
The player measures the quality of each view (how long the first frame took, rebuffering, errors, bitrate, watch time)
|
|
39
|
+
and sends it to the media host that serves the video.
|
|
40
|
+
|
|
41
|
+
### When anything is sent
|
|
42
|
+
|
|
43
|
+
Only when `src` is a Devultur delivery URL: an `http:` or `https:` URL whose path contains `/v1/media/`. Any other
|
|
44
|
+
source sends nothing, whatever `metrics` says.
|
|
45
|
+
|
|
46
|
+
### Where it goes
|
|
47
|
+
|
|
48
|
+
`POST /v1/metrics/playback` on the video's own origin, after the same path prefix: a video at
|
|
49
|
+
`https://media.example.com/v1/media/public/video/abc/hls/playlist.m3u8` reports to
|
|
50
|
+
`https://media.example.com/v1/metrics/playback`. Nothing goes to any other host. The self-host Worker answers `204` and
|
|
51
|
+
discards the body; a [plugin](plugins.md) can record it instead.
|
|
52
|
+
|
|
53
|
+
The player sends with `navigator.sendBeacon` (falling back to `fetch` with `keepalive`, `credentials: "omit"` and
|
|
54
|
+
`mode: "no-cors"`), as `text/plain` JSON. It sends every 30 seconds while something changed, when the tab is hidden,
|
|
55
|
+
when the page is closed, when the video ends, and when the player unmounts or `src` changes. A beacon that is lost is
|
|
56
|
+
never retried and never shows as an error.
|
|
57
|
+
|
|
58
|
+
### What is sent
|
|
59
|
+
|
|
60
|
+
One JSON object per beacon, with the changes since the previous one:
|
|
61
|
+
|
|
62
|
+
| Field | What it is |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `v` | Beacon format version (`1`). |
|
|
65
|
+
| `viewId` | A random id for this view (`crypto.randomUUID()`). It isn't derived from the device or the user, and isn't stored: two views of the same video never share it. |
|
|
66
|
+
| `key` | The media key after `/v1/media/` (for example `public/video/abc/hls/playlist.m3u8`), without the query string. |
|
|
67
|
+
| `seq` | The beacon's number within the view. |
|
|
68
|
+
| `engine` | `hls.js`, `native` (the browser's own HLS) or `progressive` (a plain file). |
|
|
69
|
+
| `viewStart` | `1` on the view's first beacon. |
|
|
70
|
+
| `startupMs` | Time to the first frame, on the beacon where it happened. |
|
|
71
|
+
| `watchMs` | Time spent playing. |
|
|
72
|
+
| `rebufferCount`, `rebufferMs` | How often and how long playback stalled. |
|
|
73
|
+
| `fatalErrors`, `nonFatalErrors`, `errorCode` | Playback errors, and the last error code (an hls.js or `MEDIA_ERR_*` code). |
|
|
74
|
+
| `erroredView` | `1` on the beacon with the view's first fatal error. |
|
|
75
|
+
| `renditionSwitches` | How often the quality changed. |
|
|
76
|
+
| `bitrateKbpsMs`, `bitrateWatchMs` | Bitrate × time played, and the time played at a known bitrate (for the average bitrate). |
|
|
77
|
+
| `completionDelta` | How far the furthest position reached moved, in percentage points. |
|
|
78
|
+
| `durationS` | The video's duration in seconds. |
|
|
79
|
+
| `token` | The player's `token`, only when it has a JWT's shape (three dot-separated parts), as a viewer token does: it lets the media host find which storage the beacon belongs to. A token of any other shape is never put in a beacon. |
|
|
80
|
+
|
|
81
|
+
No cookies, no local storage, no user id, no page URL and no device fingerprint. `<Preview>` from `@devultur/convex`
|
|
82
|
+
plays videos in this player and takes the same `metrics` prop. Like any request, the beacon reaches
|
|
83
|
+
the media host with the viewer's IP address, user agent and the page's origin (and its referrer, as the page's
|
|
84
|
+
referrer policy allows); the body carries none of them.
|
|
85
|
+
|
|
86
|
+
## Labels
|
|
87
|
+
|
|
88
|
+
Every string the player shows or announces can be replaced with `labels` (`VideoPlayerLabels`). Pass any subset; the
|
|
89
|
+
rest keep the English defaults (`defaultVideoPlayerLabels`). `@devultur/convex/locales/es` and `locales/es-formal`
|
|
90
|
+
export Spanish ones as `videoPlayerLabels`.
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
<VideoPlayer src={video.playlistUrl} labels={{ play: "Reproducir", pause: "Pausar" }} />
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Keyboard shortcuts
|
|
97
|
+
|
|
98
|
+
| Key | Action |
|
|
99
|
+
| --- | --- |
|
|
100
|
+
| `Space` | Play / pause |
|
|
101
|
+
| `←` `→` | Seek |
|
|
102
|
+
| `↑` `↓` | Volume |
|
|
103
|
+
| `F` | Fullscreen |
|
|
104
|
+
| `C` | Toggle captions |
|
|
105
|
+
| `M` | Mute |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devultur/convex",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Convex component for Devultur media: media records, uploads, transcodes, watch progress and React bindings",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"convex",
|
|
@@ -43,6 +43,10 @@
|
|
|
43
43
|
"import": "./dist/react.js",
|
|
44
44
|
"types": "./dist/react.d.ts"
|
|
45
45
|
},
|
|
46
|
+
"./react/hooks": {
|
|
47
|
+
"import": "./dist/react/hooks.js",
|
|
48
|
+
"types": "./dist/react/hooks.d.ts"
|
|
49
|
+
},
|
|
46
50
|
"./pdfjs": {
|
|
47
51
|
"import": "./dist/pdfjs.js",
|
|
48
52
|
"types": "./dist/pdfjs.d.ts"
|
|
@@ -61,11 +65,12 @@
|
|
|
61
65
|
"devultur-init": "./dist/bin/init.js"
|
|
62
66
|
},
|
|
63
67
|
"files": [
|
|
64
|
-
"dist"
|
|
68
|
+
"dist",
|
|
69
|
+
"docs"
|
|
65
70
|
],
|
|
66
71
|
"dependencies": {
|
|
67
|
-
"@devultur/core": "1.5.
|
|
68
|
-
"@devultur/react": "1.5.
|
|
72
|
+
"@devultur/core": "1.5.1",
|
|
73
|
+
"@devultur/react": "1.5.1"
|
|
69
74
|
},
|
|
70
75
|
"peerDependencies": {
|
|
71
76
|
"convex": ">=1.25.0",
|
|
@@ -97,7 +102,7 @@
|
|
|
97
102
|
"access": "public"
|
|
98
103
|
},
|
|
99
104
|
"scripts": {
|
|
100
|
-
"build": "tsup src/index.ts src/validators.ts src/react.tsx src/pdfjs.ts src/locales/es.ts src/locales/es-formal.ts src/bin/init.ts --format esm --dts && tsc -p src/component/tsconfig.json",
|
|
105
|
+
"build": "tsup src/index.ts src/validators.ts src/react.tsx src/react/hooks.ts src/pdfjs.ts src/locales/es.ts src/locales/es-formal.ts src/bin/init.ts --format esm --dts --clean && tsc -p src/component/tsconfig.json && node scripts/add-import-extensions.ts && node ../../scripts/copy-guides.mts",
|
|
101
106
|
"codegen": "node scripts/codegen.ts",
|
|
102
107
|
"dev": "tsup src/index.ts src/react.tsx --format esm --dts --watch",
|
|
103
108
|
"typecheck": "tsc --noEmit"
|