@devultur/convex 1.4.3 → 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 CHANGED
@@ -107,16 +107,62 @@ export const { createUploadUrl, confirmUpload, issueViewerToken, getMedia, listM
107
107
  signed-in user can't read or overwrite someone else's progress. Return `{ userId, canActForOthers: true }` for a
108
108
  caller who may (an admin resetting a learner's progress). A guard that returns no `userId` keeps the browser's, as
109
109
  `exposeApi` does: return one whenever the progress functions are exported.
110
+ - The guard may also return a `rewrite`, `{ name, args }`: each key of `args` replaces the caller's argument of that
111
+ name, on your server, before the function runs (see [Upload visibility](#upload-visibility)). `name` is the guarded
112
+ operation's, so `args` are typed for it; a rewrite named for another operation throws. The rewritten arguments
113
+ aren't validated again.
110
114
  - `visibility: "internal"` registers them as internal functions, for your own actions and mutations to call.
111
- - `config` takes what `Devultur.exposeApi` does (transcode preset, caption locales, upload lock).
112
- - Each function keeps its arguments and result types, so `api.devultur.confirmUpload` is typed as before and
113
- `<DevulturProvider api={api.devultur}>` takes them as it takes `exposeApi`'s.
115
+ - `config` takes what `Devultur.exposeApi` does (transcode preset, caption locales, upload lock and default
116
+ visibility).
117
+ - Each function keeps its arguments and returns the component's result, typed: with no annotation,
118
+ `useQuery(api.devultur.getMedia, { key })` is a `MediaDocument | null` (a `MediaRecord` with its `_id` and
119
+ `_creationTime`), and `listMediaPage` and `searchMedia` are a `PaginationResult<MediaDocument>`.
120
+ `DevulturFunctionResults` names every function's result. `<DevulturProvider api={api.devultur}>` takes them as it
121
+ takes `exposeApi`'s, whose results are the same.
114
122
 
115
123
  > **`Devultur.exposeApi` has no auth.** It returns the same functions with no guard: anyone who can reach your Convex
116
124
  > deployment can create upload URLs, confirm uploads, list the whole library and delete media. It keeps working, but
117
125
  > use it only for a deployment where every caller is trusted, or move to `defineDevulturFunctions`, which takes the
118
126
  > same `config`.
119
127
 
128
+ ## Upload visibility
129
+
130
+ `config.upload.visibility` sets the visibility of an upload whose caller passes none. Without it, the Worker's default
131
+ by type applies.
132
+
133
+ ```typescript
134
+ export const { createUploadUrl, confirmUpload } = defineDevulturFunctions(devultur, {
135
+ guard,
136
+ config: { upload: { visibility: "public" } }, // a site whose uploads are public unless the browser asks otherwise
137
+ });
138
+ ```
139
+
140
+ The browser can still pass `visibility` (`upload(file, { visibility })`, `useMediaUpload({ visibility })`). To decide
141
+ it on your server instead, rewrite it in the guard: the guard's `rewrite` replaces the browser's arguments.
142
+
143
+ ```typescript
144
+ guard: async (ctx, operation) => {
145
+ const identity = await ctx.auth.getUserIdentity();
146
+ if (!identity) throw new ConvexError("Sign in first");
147
+ // Members' uploads are always private, whatever the browser asked for.
148
+ if (operation.name === "createUploadUrl") {
149
+ return { userId: identity.subject, rewrite: { name: "createUploadUrl", args: { visibility: "private" } } };
150
+ }
151
+ return { userId: identity.subject };
152
+ },
153
+ ```
154
+
155
+ Which one wins, from first to last:
156
+
157
+ 1. The guard's `rewrite.args.visibility`.
158
+ 2. The caller's `visibility`.
159
+ 3. `config.upload.visibility`.
160
+ 4. The Worker's default by type.
161
+
162
+ The guard sees the caller's arguments as they arrived (`operation.args.visibility` is `undefined` when the browser
163
+ passed none): the config's default is applied after the guard. `config.upload.lock` isn't an argument, so neither
164
+ the browser nor the guard's `rewrite` change it.
165
+
120
166
  ## Upload progress
121
167
 
122
168
  `upload(file, { onProgress, signal })` reports the PUT's progress and resolves to the stored reference:
@@ -175,7 +221,7 @@ ignored. Your action returns what the component's action returns: `{ token, expi
175
221
  ### Display-only apps
176
222
 
177
223
  An app that only shows media (no uploads, no deletes) uses `<DevulturViewerProvider>`, which needs only the base URL
178
- and a viewer token action:
224
+ and, for private media, a viewer token action:
179
225
 
180
226
  ```tsx
181
227
  import { DevulturViewerProvider, Preview } from "@devultur/convex/react";
@@ -194,13 +240,64 @@ import { DevulturViewerProvider, Preview } from "@devultur/convex/react";
194
240
  that need the `api` (`useDevulturApi`, `useMediaUpload`, `<MediaLibrary>`, `<MediaPicker>`), throw a
195
241
  `DevulturCapabilityError` whose `code` is `capability_unavailable`.
196
242
 
243
+ Like `<DevulturProvider>`, it issues the viewer token as soon as it mounts. With `lazyToken`, it issues it only when
244
+ a private media is shown, and renews it from then on: while every media on the page is public, it calls nothing,
245
+ `tokenStatus` stays `"idle"` and `token` stays `null`. Code that reads `token` itself then calls
246
+ `useDevultur().requestToken()` first; the built-in components and hooks do it for private media.
247
+
248
+ ```tsx
249
+ <DevulturViewerProvider baseUrl={MEDIA_URL} issueViewerToken={api.devultur.issueViewerToken} lazyToken>
250
+ <Gallery />
251
+ </DevulturViewerProvider>
252
+ ```
253
+
254
+ When all the media is public, leave `issueViewerToken` out. The provider then needs no Convex client and issues
255
+ nothing:
256
+
257
+ ```tsx
258
+ <DevulturViewerProvider baseUrl={MEDIA_URL}>
259
+ <Image media={post.cover} alt={post.title} />
260
+ </DevulturViewerProvider>
261
+ ```
262
+
263
+ A private media shown under it fails with `token_unavailable`, and `tokenError` says the provider has no
264
+ `issueViewerToken`.
265
+
266
+ ### Components outside a provider
267
+
268
+ `useDevultur()` throws outside `<DevulturProvider>` and `<DevulturViewerProvider>`. A component that also renders
269
+ without one uses `useDevulturOptional()`, which returns the same value, or `null` outside both:
270
+
271
+ ```tsx
272
+ function Avatar({ media, fallbackUrl }) {
273
+ const devultur = useDevulturOptional();
274
+ if (!devultur || !media) return <img src={fallbackUrl} alt="" />;
275
+ return <Image media={media} alt="" width={64} />;
276
+ }
277
+ ```
278
+
279
+ ### Only the hooks
280
+
281
+ `@devultur/convex/react/hooks` exports the contexts and the hooks that only read them, without the components or
282
+ Convex's client: `useDevultur`, `useDevulturOptional`, `useDevulturApi`, `useDevulturProgress`, `useMediaUrl`,
283
+ `useVideoProps`, and the `DevulturContext`, `DevulturApiContext` and `DevulturProgressContext` objects. A shared
284
+ component that only checks for a provider adds well under 1 kB gzipped to the bundle:
285
+
286
+ ```tsx
287
+ import { useDevulturOptional } from "@devultur/convex/react/hooks";
288
+ ```
289
+
290
+ They're the very objects `@devultur/convex/react` exports, so a `<DevulturProvider>` or `<DevulturViewerProvider>`
291
+ imported from there fills them. The hooks that call your Convex functions (`useDevulturMedia`, `useVideoState`,
292
+ `useMediaUpload`, `useMediaLibrary`) stay in `@devultur/convex/react`, with the components.
293
+
197
294
  ### When the viewer token can't be issued
198
295
 
199
296
  `useDevultur()` tells where the viewer token stands:
200
297
 
201
298
  ```tsx
202
299
  const { tokenStatus, tokenError, retryToken } = useDevultur();
203
- // tokenStatus: "idle" | "loading" | "ready" | "error"
300
+ // tokenStatus: "idle" (not asked for yet) | "loading" | "ready" | "error"
204
301
  if (tokenStatus === "error") return <button onClick={retryToken}>Try again</button>; // tokenError has why
205
302
  ```
206
303
 
@@ -210,6 +307,11 @@ there until `retryToken()`: a failed issuance isn't retried on its own. Meanwhil
210
307
  `<Preview>` passes it to its `fallback`, `<PdfThumbnail>` and `<Image>` render their `fallback` (`<Image onLoadError>`
211
308
  receives it), and `<DownloadLink onDownloadError>` receives it on click. They load once `retryToken()` gets a token.
212
309
 
310
+ `<Preview>`'s video plays in `<VideoPlayer>` from `@devultur/react`, with its playback metrics on: anonymous, no
311
+ cookies, sent to the media host (see [Playback metrics](https://github.com/devultur/devultur/blob/main/docs/guides/video-player.md#playback-metrics),
312
+ also in `node_modules/@devultur/convex/docs/guides/video-player.md`).
313
+ `<Preview metrics={false}>` turns them off. A private video shows `loading` until its viewer token is issued.
314
+
213
315
  `<Preview>`'s video re-issues the token once when the delivery answers the playlist or a segment with a 401
214
316
  `session_mismatch`; after that, or on any other 401, it renders its `fallback` with `session_mismatch` or
215
317
  `token_unavailable`. `<VideoPlayer onError>` (from `@devultur/react`) reports these failures when you render the
@@ -658,8 +760,8 @@ the same name.
658
760
 
659
761
  `@devultur/convex/locales/es` addresses the reader with «tú» ("Vuelve a intentarlo"). For «usted» ("Vuelva a
660
762
  intentarlo", "No tiene permiso…"), import the same names from `@devultur/convex/locales/es-formal`: it exports exactly
661
- what `locales/es` does (error, attachment, preview, library and picker labels), and differs only in the strings that
662
- address the reader.
763
+ what `locales/es` does (error, attachment, preview, library, picker and video player labels), and differs only in the
764
+ strings that address the reader.
663
765
 
664
766
  ```tsx
665
767
  import { describeDevulturError } from "@devultur/convex";
@@ -685,6 +787,18 @@ Pass a partial map to change some messages and keep the rest, or a full one for
685
787
  describeDevulturError(error, { ...spanishLabels, lock_conflict: "Alguien más acaba de cambiarlo." });
686
788
  ```
687
789
 
790
+ ### Video player labels
791
+
792
+ `<VideoPlayer>` from `@devultur/react` takes `labels` like the components here do. Both Spanish locales export
793
+ `videoPlayerLabels`:
794
+
795
+ ```tsx
796
+ import { VideoPlayer } from "@devultur/react";
797
+ import { videoPlayerLabels } from "@devultur/convex/locales/es";
798
+
799
+ <VideoPlayer {...useVideoProps(lesson.video)} labels={videoPlayerLabels} />;
800
+ ```
801
+
688
802
  ## Object lock
689
803
 
690
804
  - `devultur.createUploadUrl(ctx, { filename, contentType, lock, hold })` locks a file from its first write.
@@ -701,6 +815,8 @@ what's enforced and the caveats.
701
815
  ## Documentation
702
816
 
703
817
  Guides and API reference: [github.com/devultur/devultur/tree/main/docs](https://github.com/devultur/devultur/tree/main/docs).
818
+ The guides this README links to ship with the package, for the installed version, in
819
+ `node_modules/@devultur/convex/docs/guides/`: `video-player.md`, `self-host.md`, `plugins.md` and `object-lock.md`.
704
820
 
705
821
  ## License
706
822
 
@@ -0,0 +1,110 @@
1
+ // src/capability-error.ts
2
+ var DevulturCapabilityError = class extends Error {
3
+ code = "capability_unavailable";
4
+ capability;
5
+ constructor(capability) {
6
+ super(
7
+ `Devultur: \`${capability}\` isn't available under <DevulturViewerProvider>. Use <DevulturProvider api> with the actions it needs.`
8
+ );
9
+ this.name = "DevulturCapabilityError";
10
+ this.capability = capability;
11
+ }
12
+ };
13
+
14
+ // src/devultur-context.ts
15
+ import React from "react";
16
+ var DevulturContext = /* @__PURE__ */ React.createContext(null);
17
+ var DevulturApiContext = /* @__PURE__ */ React.createContext(null);
18
+ function useDevultur() {
19
+ const context = React.use(DevulturContext);
20
+ if (!context) throw new Error("useDevultur must be used within <DevulturProvider> or <DevulturViewerProvider>");
21
+ return context;
22
+ }
23
+ function useDevulturOptional() {
24
+ return React.use(DevulturContext);
25
+ }
26
+ function useDevulturApi() {
27
+ const api = React.use(DevulturApiContext);
28
+ if (api) return api;
29
+ if (React.use(DevulturContext)) throw new DevulturCapabilityError("api");
30
+ throw new Error("useDevulturApi must be used within <DevulturProvider>");
31
+ }
32
+
33
+ // src/progress-context.ts
34
+ import React2 from "react";
35
+ var DevulturProgressContext = /* @__PURE__ */ React2.createContext(null);
36
+ function useDevulturProgress() {
37
+ const context = React2.use(DevulturProgressContext);
38
+ if (!context) throw new Error("useDevulturProgress requires progress API in DevulturProvider");
39
+ return context;
40
+ }
41
+
42
+ // src/media-url-hooks.ts
43
+ import React4 from "react";
44
+ import {
45
+ buildMediaUrl,
46
+ CaptionLocaleLabel,
47
+ mediaCaptionPath,
48
+ mediaPath,
49
+ visibilityFromKey
50
+ } from "@devultur/core";
51
+
52
+ // src/request-token.ts
53
+ import React3 from "react";
54
+ function useRequestTokenWhen(session, isNeeded) {
55
+ const { requestToken, token } = session;
56
+ const hasToken = token !== null;
57
+ React3.useEffect(() => {
58
+ if (isNeeded && !hasToken) requestToken();
59
+ }, [isNeeded, hasToken, requestToken]);
60
+ }
61
+
62
+ // src/media-url-hooks.ts
63
+ function useMediaUrl(key, options) {
64
+ const devultur = useDevultur();
65
+ const { token, baseUrl } = devultur;
66
+ useRequestTokenWhen(devultur, Boolean(key) && visibilityFromKey(key ?? "") !== "public");
67
+ return React4.useMemo(() => {
68
+ if (!key) return null;
69
+ if (!baseUrl) throw new Error("useMediaUrl requires `baseUrl` on <DevulturProvider>");
70
+ const isPublic = visibilityFromKey(key) === "public";
71
+ return buildMediaUrl(baseUrl, key, { ...options, token: isPublic ? void 0 : token ?? void 0 });
72
+ }, [key, baseUrl, token, options]);
73
+ }
74
+ function useVideoProps(media, options) {
75
+ const devultur = useDevultur();
76
+ const { token, baseUrl, sessionSecret } = devultur;
77
+ useRequestTokenWhen(devultur, media?.kind === "video" && media.visibility === "private");
78
+ const locales = options?.locales;
79
+ return React4.useMemo(() => {
80
+ if (media?.kind !== "video") return null;
81
+ if (!baseUrl) throw new Error("useVideoProps requires `baseUrl` on <DevulturProvider>");
82
+ const viewerToken = media.visibility === "private" ? token ?? void 0 : void 0;
83
+ const captions = (locales ?? []).flatMap((locale) => {
84
+ const path = mediaCaptionPath(media, locale);
85
+ if (!path) return [];
86
+ const label = CaptionLocaleLabel[locale] ?? locale;
87
+ return [{ locale, label, src: buildMediaUrl(baseUrl, path, { token: viewerToken }) }];
88
+ });
89
+ return {
90
+ src: buildMediaUrl(baseUrl, mediaPath(media), { token: viewerToken }),
91
+ token: viewerToken,
92
+ captions,
93
+ sessionSecret: media.visibility === "private" ? sessionSecret : void 0
94
+ };
95
+ }, [media, baseUrl, token, locales, sessionSecret]);
96
+ }
97
+
98
+ export {
99
+ DevulturCapabilityError,
100
+ DevulturContext,
101
+ DevulturApiContext,
102
+ useDevultur,
103
+ useDevulturOptional,
104
+ useDevulturApi,
105
+ DevulturProgressContext,
106
+ useDevulturProgress,
107
+ useRequestTokenWhen,
108
+ useMediaUrl,
109
+ useVideoProps
110
+ };
@@ -175,11 +175,30 @@ var mediaPickerLabels = {
175
175
  removed: (name) => `${name} se quit\xF3.`,
176
176
  cleared: "Se limpi\xF3 la selecci\xF3n."
177
177
  };
178
+ var videoPlayerLabels = {
179
+ player: "Reproductor de video",
180
+ controls: "Controles del video",
181
+ play: "Reproducir",
182
+ pause: "Pausar",
183
+ seek: "Posici\xF3n del video",
184
+ mute: "Silenciar",
185
+ unmute: "Activar el sonido",
186
+ volume: "Volumen",
187
+ captions: "Subt\xEDtulos",
188
+ captionsOff: "Desactivados",
189
+ quality: "Calidad del video",
190
+ qualityAuto: "Autom\xE1tica",
191
+ speed: "Velocidad de reproducci\xF3n",
192
+ speedNormal: "Normal",
193
+ fullscreen: "Pantalla completa",
194
+ exitFullscreen: "Salir de pantalla completa"
195
+ };
178
196
 
179
197
  export {
180
198
  devulturErrorLabels,
181
199
  previewLabels,
182
200
  attachmentLabels,
183
201
  mediaLibraryLabels,
184
- mediaPickerLabels
202
+ mediaPickerLabels,
203
+ videoPlayerLabels
185
204
  };
@@ -1,7 +1,7 @@
1
1
  import { isBearerOf } from "@devultur/core";
2
2
  import { httpRouter } from "convex/server";
3
- import { internal } from "./_generated/api";
4
- import { env, httpAction } from "./_generated/server";
3
+ import { internal } from "./_generated/api.js";
4
+ import { env, httpAction } from "./_generated/server.js";
5
5
  const http = httpRouter();
6
6
  http.route({
7
7
  path: "/webhook",
@@ -1,6 +1,6 @@
1
1
  import { type PaginationOptions, type PaginationResult } from "convex/server";
2
- import type { Doc } from "./_generated/dataModel";
3
- import type { QueryCtx } from "./_generated/server";
2
+ import type { Doc } from "./_generated/dataModel.js";
3
+ import type { QueryCtx } from "./_generated/server.js";
4
4
  type MediaType = Doc<"media">["type"];
5
5
  export declare const BACKFILL_BATCH_SIZE: {
6
6
  readonly default: 100;
@@ -2,9 +2,9 @@
2
2
  // records from before 1.4.0 the `searchText` they were written without.
3
3
  import { paginationOptsValidator } from "convex/server";
4
4
  import { v } from "convex/values";
5
- import { internal } from "./_generated/api";
6
- import { internalMutation, mutation, query } from "./_generated/server";
7
- import { missingSearchText } from "./mediaMeta";
5
+ import { internal } from "./_generated/api.js";
6
+ import { internalMutation, mutation, query } from "./_generated/server.js";
7
+ import { missingSearchText } from "./mediaMeta.js";
8
8
  const mediaType = v.union(v.literal("video"), v.literal("image"), v.literal("file"));
9
9
  /** Longest search kept: Convex matches at most 16 terms, so a longer query only costs. */
10
10
  const SEARCH_MAX_LENGTH = 200;
@@ -5,9 +5,9 @@
5
5
  // `lock_privilege_required`, `lock_conflict`, ...); see workerErrors.ts.
6
6
  import { HOLD_PAGE_SIZE } from "@devultur/core";
7
7
  import { v } from "convex/values";
8
- import { derivativesValidator, retentionModeValidator } from "./lockValidators";
9
- import { getMediaClient } from "./mediaClient";
10
- import { workerAction } from "./workerErrors";
8
+ import { derivativesValidator, retentionModeValidator } from "./lockValidators.js";
9
+ import { getMediaClient } from "./mediaClient.js";
10
+ import { workerAction } from "./workerErrors.js";
11
11
  const keysValidator = v.array(v.string());
12
12
  export const getLock = workerAction({
13
13
  args: { key: v.string() },
@@ -1,6 +1,6 @@
1
1
  // The component's client of the media Worker, from the env the app mounts the component with.
2
2
  import { createWorkerClient } from "@devultur/core";
3
- import { env } from "./_generated/server";
3
+ import { env } from "./_generated/server.js";
4
4
  export function getMediaClient() {
5
5
  return createWorkerClient(env.DEVULTUR_SECRET, env.DEVULTUR_API_URL);
6
6
  }
@@ -1,8 +1,8 @@
1
1
  // Editing a media record's descriptive metadata (title, alt text, tags). The upload's own facts (size, dimensions,
2
2
  // duration, page count) are recorded by `uploads.ts` and the transcode webhook, never edited here.
3
3
  import { v } from "convex/values";
4
- import { mutation } from "./_generated/server";
5
- import { mediaMetaPatch, searchTextOf } from "./mediaMeta";
4
+ import { mutation } from "./_generated/server.js";
5
+ import { mediaMetaPatch, searchTextOf } from "./mediaMeta.js";
6
6
  /**
7
7
  * Sets a record's `title`, `alt` and/or `tags` (only the ones passed). Trimmed; an empty title or alt clears it, and
8
8
  * tags are deduplicated. Throws `invalid_media_metadata` past the limits (title 200, alt 1000, 20 tags of 50). Returns
@@ -1,15 +1,15 @@
1
1
  import { DEFAULT_TOKEN_EXPIRY_SECONDS, extractId, MediaEventName, MediaStatus } from "@devultur/core";
2
2
  import { paginationOptsValidator } from "convex/server";
3
3
  import { v } from "convex/values";
4
- import { api, internal } from "./_generated/api";
5
- import { internalMutation, mutation, query } from "./_generated/server";
6
- import { listNewestFirst } from "./library";
7
- import { objectLockValidator } from "./lockValidators";
8
- import { getMediaClient } from "./mediaClient";
9
- import { missingSearchText, searchTextOf } from "./mediaMeta";
10
- import { transcodeOutputFields } from "./schema";
11
- import { UPLOAD_CONFIRM_BACKSTOP_MS } from "./uploads";
12
- import { workerAction } from "./workerErrors";
4
+ import { api, internal } from "./_generated/api.js";
5
+ import { internalMutation, mutation, query } from "./_generated/server.js";
6
+ import { listNewestFirst } from "./library.js";
7
+ import { objectLockValidator } from "./lockValidators.js";
8
+ import { getMediaClient } from "./mediaClient.js";
9
+ import { missingSearchText, searchTextOf } from "./mediaMeta.js";
10
+ import { transcodeOutputFields } from "./schema.js";
11
+ import { UPLOAD_CONFIRM_BACKSTOP_MS } from "./uploads.js";
12
+ import { workerAction } from "./workerErrors.js";
13
13
  // Webhook path RELATIVE to the component. Convex gives the component a `CONVEX_SITE_URL` that ALREADY
14
14
  // includes its mount prefix (e.g. `${site}/devultur`), so `${CONVEX_SITE_URL}/webhook` resolves to the
15
15
  // mounted route (`${site}/devultur/webhook`) without duplicating the prefix. Do NOT add `/devultur/` here
@@ -1,11 +1,11 @@
1
1
  import { extractId, sanitizeFilename } from "@devultur/core";
2
2
  import { ConvexError, v } from "convex/values";
3
- import { internal } from "./_generated/api";
4
- import { internalAction, internalMutation } from "./_generated/server";
5
- import { getMediaClient } from "./mediaClient";
6
- import { searchTextOf, validUploadHints } from "./mediaMeta";
7
- import { parseUploadKey } from "./uploadKeys";
8
- import { workerAction } from "./workerErrors";
3
+ import { internal } from "./_generated/api.js";
4
+ import { internalAction, internalMutation } from "./_generated/server.js";
5
+ import { getMediaClient } from "./mediaClient.js";
6
+ import { searchTextOf, validUploadHints } from "./mediaMeta.js";
7
+ import { parseUploadKey } from "./uploadKeys.js";
8
+ import { workerAction } from "./workerErrors.js";
9
9
  // Past the Worker's longest upload window (a multipart upload can run for 24 hours), so the scheduled check never runs
10
10
  // while the bytes are still on their way.
11
11
  export const UPLOAD_CONFIRM_BACKSTOP_MS = 25 * 60 * 60 * 1000;
@@ -1,5 +1,5 @@
1
1
  import { type Value } from "convex/values";
2
- import { action } from "./_generated/server";
2
+ import { action } from "./_generated/server.js";
3
3
  /** `data` of the `ConvexError` a component action throws when the Worker refuses a request. */
4
4
  export interface DevulturErrorData {
5
5
  /** The Worker's stable code (`retention_active`, `file_too_large`, ...), or `worker_error` when it sent none. */
@@ -4,7 +4,7 @@
4
4
  // (`retention_active`, `lock_conflict`, `compliance_extend_only`, ...) and `error.data.details`.
5
5
  import { DevulturApiError } from "@devultur/core";
6
6
  import { ConvexError } from "convex/values";
7
- import { action } from "./_generated/server";
7
+ import { action } from "./_generated/server.js";
8
8
  function isApiError(error) {
9
9
  if (error instanceof DevulturApiError)
10
10
  return true;
@@ -0,0 +1,171 @@
1
+ import React from 'react';
2
+ import { DevulturMedia, MediaUrlOptions, Visibility } from '@devultur/core';
3
+ import { UploadProgress } from '@devultur/react';
4
+ import { FunctionReference } from 'convex/server';
5
+ import { Value } from 'convex/values';
6
+
7
+ /** What a viewer token action returns: the token with its lifetime, or the bare token (default lifetime). */
8
+ type ViewerTokenResult = {
9
+ token: string;
10
+ expiresIn: number;
11
+ } | string;
12
+ /** Extra arguments sent with `sessionSecret` to the viewer token action. */
13
+ type ViewerTokenArgs = Record<string, Value>;
14
+ /**
15
+ * Where the viewer token for the current `tokenArgs` stands: not requested yet, being issued (no token yet), held, or
16
+ * failed (the action threw or returned no token). A renewal in flight, or one that failed, keeps `"ready"`.
17
+ */
18
+ type TokenStatus = "idle" | "loading" | "ready" | "error";
19
+
20
+ /** Transform/delivery options for media URLs (without `token`, which the context injects). */
21
+ type MediaRenderOptions = Omit<MediaUrlOptions, "token">;
22
+ /**
23
+ * The viewer token action: the component's `issueViewerToken` (as `defineDevulturFunctions` or `exposeApi` export it,
24
+ * where `sessionSecret` is optional) or an app's own, which may require it. The providers always send it.
25
+ */
26
+ type ViewerTokenAction = FunctionReference<"action", "public", {
27
+ sessionSecret?: string;
28
+ }>;
29
+ interface MediaSession {
30
+ token: string | null;
31
+ /** Where the viewer token stands; `"error"` makes private previews fail fast with `token_unavailable`. */
32
+ tokenStatus: TokenStatus;
33
+ /** Why the last issuance failed (the action threw or returned no token), while `tokenStatus` is `"error"`. */
34
+ tokenError: Error | null;
35
+ /** Issues the viewer token again (after a failure, for example). Resolves to the new token, or `null`. */
36
+ retryToken: () => Promise<string | null>;
37
+ /**
38
+ * Asks for the viewer token where it's issued lazily (`<DevulturViewerProvider lazyToken>`): the first private media
39
+ * shown calls it, and so must code that reads `token` itself. A no-op once asked, and where the token is issued
40
+ * right away (the default).
41
+ */
42
+ requestToken: () => void;
43
+ baseUrl: string | null;
44
+ /** Browser session secret → sent in `${SESSION_HEADER}` on every private delivery. */
45
+ sessionSecret: string;
46
+ /** Delivery URL from a reference (non-hook → usable in handlers and `.map`). Token-transparent. */
47
+ url: (media: DevulturMedia, options?: MediaRenderOptions) => string;
48
+ /** Downloads a video's subtitle text for a locale (auth-transparent). */
49
+ getCaptionsText: (media: DevulturMedia, locale: string) => Promise<string>;
50
+ /**
51
+ * Re-issues the viewer token (new session fingerprint with the current UA/IP) and updates the context.
52
+ * Single-flight: concurrent calls share the same issuance. Returns the new token (or null if it
53
+ * failed). The render uses it to transparently recover from a 401 `session_mismatch`.
54
+ */
55
+ reissueToken: () => Promise<string | null>;
56
+ }
57
+
58
+ type AnyAction = FunctionReference<"action">;
59
+ type AnyMutation = FunctionReference<"mutation">;
60
+ type AnyQuery = FunctionReference<"query">;
61
+ interface DevulturApi {
62
+ issueViewerToken: AnyAction;
63
+ createUploadUrl: AnyAction;
64
+ /** Records an upload in the component after its PUT, so it shows in `listMedia`. Optional (uploads still work). */
65
+ confirmUpload?: AnyAction;
66
+ deleteMedia: AnyAction;
67
+ /** Deletes the full folder of a video (raw/hls/thumbnails/captions). Required to delete videos. */
68
+ deleteVideo?: AnyAction;
69
+ /** Requests the transcode of an uploaded video. Required by `useMediaUpload` for auto-process. */
70
+ processVideo?: AnyAction;
71
+ getMedia?: AnyQuery;
72
+ /** The library, newest first. Required by `useMediaLibrary` and `<MediaLibrary>`. */
73
+ listMediaPage?: AnyQuery;
74
+ /** The library matching a search. Required by `useMediaLibrary` and `<MediaLibrary>` to search. */
75
+ searchMedia?: AnyQuery;
76
+ saveProgress?: AnyMutation;
77
+ loadProgress?: AnyQuery;
78
+ markComplete?: AnyMutation;
79
+ resetProgress?: AnyMutation;
80
+ }
81
+ interface UploadOptions {
82
+ /**
83
+ * Visibility override for this resource. Omitted → `config.upload.visibility` on your server, else the Worker's
84
+ * default by type. Your guard can still rewrite it.
85
+ */
86
+ visibility?: Visibility;
87
+ /** Progress of the PUT: bytes sent, total bytes and their fraction (0 to 1). */
88
+ onProgress?: (progress: UploadProgress) => void;
89
+ /** Aborts the PUT → `upload()` rejects with an `AbortError`. */
90
+ signal?: AbortSignal;
91
+ /** A PDF's page count to record with it, a hint (`countPdfPages` from `@devultur/convex/pdfjs` counts it). */
92
+ pageCount?: number;
93
+ }
94
+ /**
95
+ * The reference `upload()` resolves to: a `DevulturMedia` whose `key` is always set (the uploaded object's key; a
96
+ * video's raw file, what `processVideo` takes). It stores as-is in a `dv.media()` field.
97
+ */
98
+ type UploadedMedia = DevulturMedia & {
99
+ readonly key: string;
100
+ };
101
+ /**
102
+ * What `useDevultur()` returns. Under a `<DevulturViewerProvider>`, `uploadUrl`, `upload` and `deleteMedia` throw a
103
+ * `DevulturCapabilityError` (`capability_unavailable`).
104
+ */
105
+ interface DevulturContextValue extends MediaSession {
106
+ uploadUrl: (file: File) => Promise<{
107
+ url: string;
108
+ key: string;
109
+ }>;
110
+ /** Uploads a file (reporting progress), records it in the component, and returns the reference ready to save. */
111
+ upload: (file: File, options?: UploadOptions) => Promise<UploadedMedia>;
112
+ /** Deletes a media by reference: video → full folder; image/file → the object. */
113
+ deleteMedia: (media: DevulturMedia) => void;
114
+ }
115
+ declare const DevulturContext: React.Context<DevulturContextValue | null>;
116
+ declare const DevulturApiContext: React.Context<DevulturApi | null>;
117
+ declare function useDevultur(): DevulturContextValue;
118
+ /**
119
+ * `useDevultur()` for components that also render outside a provider: the same value under `<DevulturProvider>` or
120
+ * `<DevulturViewerProvider>`, and `null` outside both instead of throwing.
121
+ */
122
+ declare function useDevulturOptional(): DevulturContextValue | null;
123
+ /**
124
+ * The `api` passed to `<DevulturProvider>`: your app's exported Devultur functions. Under a `<DevulturViewerProvider>`
125
+ * it throws a `DevulturCapabilityError`.
126
+ */
127
+ declare function useDevulturApi(): DevulturApi;
128
+
129
+ /**
130
+ * Ready-to-use delivery URL for a key, with the context's viewer token already injected. The consumer
131
+ * never touches the token or the query — it only declares what it wants (download, resize, etc.).
132
+ *
133
+ * @example
134
+ * <img src={useMediaUrl(key)} />
135
+ * <a href={useMediaUrl(key, { download: true })} download>Download</a>
136
+ * <img src={useMediaUrl(key, { width: 512, format: "webp" })} />
137
+ */
138
+ declare function useMediaUrl(key: string | null | undefined, options?: MediaRenderOptions): string | null;
139
+ interface VideoProps {
140
+ src: string;
141
+ token: string | undefined;
142
+ captions: {
143
+ locale: string;
144
+ label: string;
145
+ src: string;
146
+ }[];
147
+ /** Session secret → `<VideoPlayer>` sends it in `${SESSION_HEADER}` on hls.js/captions requests. */
148
+ sessionSecret?: string;
149
+ }
150
+ /**
151
+ * Token-transparent props for `<VideoPlayer>` from a video reference: builds the HLS playlist `src`,
152
+ * the viewer `token` (only if private), and the `captions` for the requested locales.
153
+ * The consumer just does `<VideoPlayer {...useVideoProps(lesson.video, { locales })} />`.
154
+ */
155
+ declare function useVideoProps(media: DevulturMedia | null | undefined, options?: {
156
+ locales?: string[];
157
+ }): VideoProps | null;
158
+
159
+ interface DevulturProgressContextValue {
160
+ save: (videoId: string, position: number) => Promise<void>;
161
+ load: (videoId: string) => Promise<{
162
+ lastPosition: number;
163
+ completedAt: Date | null;
164
+ } | null>;
165
+ markComplete: (videoId: string) => Promise<void>;
166
+ reset: (videoId: string) => Promise<void>;
167
+ }
168
+ declare const DevulturProgressContext: React.Context<DevulturProgressContextValue | null>;
169
+ declare function useDevulturProgress(): DevulturProgressContextValue;
170
+
171
+ export { type DevulturApi as D, type TokenStatus as T, type UploadOptions as U, type VideoProps as V, type ViewerTokenAction as a, type ViewerTokenArgs as b, DevulturApiContext as c, DevulturContext as d, type DevulturContextValue as e, DevulturProgressContext as f, type DevulturProgressContextValue as g, type UploadedMedia as h, type ViewerTokenResult as i, useDevulturApi as j, useDevulturOptional as k, useDevulturProgress as l, useMediaUrl as m, useVideoProps as n, useDevultur as u };