@ai-matrx/media 0.2.3 → 0.3.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,60 @@
1
1
  # @ai-matrx/media
2
2
 
3
+ ## 0.3.0 — 2026-08-30
4
+
5
+ **A terminal media failure can never be silent again.** Born from the
6
+ 2026-08-30 outage: server-side cookie-secret drift killed every private image
7
+ on prod, the honest "unavailable" panel rendered — and nothing reached the
8
+ host's error system, because the package had no diagnostics port and the host
9
+ cannot capture what it never learns about. Per ruling C22 the capture logic
10
+ now lives IN the package. Additive; no default behavior changes for renders.
11
+
12
+ - **New `diagnostics` port on `MediaHostPorts`** —
13
+ `diagnostics?: { capture?: (info: MediaFailureInfo) => void }`.
14
+ `MediaFailureInfo` is strictly typed: `source: "media"`, a
15
+ `phase` (`"resolve" | "session" | "blob" | "element-load" | "recovery"`),
16
+ a durable `mediaRef` identity (file id / query-stripped URL key — NEVER a
17
+ signed URL), `status?` when known, a typed `retryOutcome?`, `terminal`,
18
+ and `message`. **Absent port → the package `console.error`s the same
19
+ structured payload** (the loud default) — never fully silent. A throwing
20
+ sink also falls back to the console.
21
+ - **Every terminal failure path emits exactly one capture**, latched per
22
+ media identity (no spam across re-renders, remounts, or the panel
23
+ backstop): a throwing `resolve()` (phase `resolve`), a `getBlob()`
24
+ rejection (phase `blob`, with the structural HTTP status when the error
25
+ carries one), a rejecting `recoverLoadError` (phase `session`,
26
+ `recovery-failed`), a terminal verdict after the ONE granted retry (phase
27
+ `element-load`, `retried`), a terminal verdict on the first attempt (phase
28
+ `recovery`, `not-attempted`), an unrecoverable element failure (phase
29
+ `element-load`, `not-attempted`) — plus the unavailable panel mount as a
30
+ latch-deduped BACKSTOP (if the panel is on screen and no path captured,
31
+ the mount screams).
32
+ - **`MediaClient.recoverLoadError` may now return a structured
33
+ `MediaRecoveryReport`** (`{ verdict, status?, secretSource? }`) alongside
34
+ the still-fully-supported bare `"retry" | "terminal"` strings — a widening,
35
+ zero change required in any implementation. When the client forwards the
36
+ `/files/session` mint's `secret_source` and it is `"ephemeral"`, the
37
+ package captures ONE app-wide non-terminal warning ("server cookie secret
38
+ is ephemeral — private renders will break across restarts/instances") the
39
+ moment it is seen — before the outage, not after. The field is tolerated
40
+ absent.
41
+ - New `/core` exports: `useMediaFailureCapture`, `emitMediaFailure`,
42
+ `failureMediaIdentity`, `resetMediaFailureLatch` (tests + auth boundaries).
43
+ Root types: `MediaFailureInfo`, `MediaFailurePhase`, `MediaRetryOutcome`,
44
+ `MediaDiagnosticsPort`, `MediaRecoveryVerdict`, `MediaRecoveryReport`.
45
+ - `useMediaLoadRecovery` gains an optional `failureRef` option so failures
46
+ are reported against the durable ref identity instead of the (stripped)
47
+ URL; `InlineMediaRef`/`MediaThumbnail` pass it automatically.
48
+
49
+ **Consumer action:** bind the port —
50
+ `ports.diagnostics = { capture: (info) => yourErrorCapture(info) }` on the
51
+ object passed to `MediaProvider`/`MediaHostPorts` (matrx-frontend: wire to
52
+ `captureError` in `lib/diagnostics/errorCaptureStore`). Without the binding
53
+ you get loud console.error diagnostics; with it, failures land in your error
54
+ system. Clients implementing `MediaClient` SHOULD start returning
55
+ `MediaRecoveryReport` from `recoverLoadError` with the mint's
56
+ `secret_source` when the server sends one.
57
+
3
58
  ## 0.2.3 — 2026-08-30
4
59
 
5
60
  - `@ai-matrx/associations` is now a REAL dependency (`^0.5.2`) instead of an
package/README.md CHANGED
@@ -124,6 +124,28 @@ automatically (it brings `@ai-matrx/design-system` with it). Nothing extra to
124
124
  resolve; association affordances still degrade to absent at runtime when no
125
125
  `AssociationsProvider` wraps the tree. The other entries never touch it.
126
126
 
127
+ ### Diagnostics — a terminal failure is never silent
128
+
129
+ Every dead render (a refused `resolve()`, failed private-pixel bytes, a load
130
+ error the ONE recovery retry couldn't fix, a failed session mint) emits
131
+ exactly one strictly-typed `MediaFailureInfo` — `phase`, durable `mediaRef`
132
+ identity (never a signed URL), `status?`, `retryOutcome?`, `terminal`,
133
+ `message` — latched per media identity so re-renders never spam. Bind the
134
+ sink to your error system:
135
+
136
+ ```tsx
137
+ <MediaProvider
138
+ client={client}
139
+ ports={{ diagnostics: { capture: (info) => myErrorCapture(info) } }}
140
+ >
141
+ ```
142
+
143
+ Without a bound sink the package `console.error`s the same payload — loud by
144
+ default, never silent. If your `MediaClient.recoverLoadError` returns a
145
+ `MediaRecoveryReport` carrying the session mint's
146
+ `secret_source: "ephemeral"`, the package immediately captures an app-wide
147
+ warning that private renders will break across server restarts/instances.
148
+
127
149
  ## Styling
128
150
 
129
151
  `/react` uses Tailwind utility classes with semantic tokens (`bg-muted`,
package/dist/core.cjs CHANGED
@@ -22,12 +22,16 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
22
22
  var core_exports = {};
23
23
  __export(core_exports, {
24
24
  MediaProvider: () => MediaProvider,
25
+ emitMediaFailure: () => emitMediaFailure,
26
+ failureMediaIdentity: () => failureMediaIdentity,
25
27
  getFileKindDetails: () => getFileKindDetails,
26
28
  getFolderKindDetails: () => getFolderKindDetails,
29
+ resetMediaFailureLatch: () => resetMediaFailureLatch,
27
30
  stableRefKey: () => stableRefKey,
28
31
  useMediaActions: () => useMediaActions,
29
32
  useMediaBlob: () => useMediaBlob,
30
33
  useMediaClient: () => useMediaClient,
34
+ useMediaFailureCapture: () => useMediaFailureCapture,
31
35
  useMediaHostPorts: () => useMediaHostPorts,
32
36
  useMediaLoadRecovery: () => useMediaLoadRecovery,
33
37
  useMediaResolution: () => useMediaResolution,
@@ -82,29 +86,107 @@ function stableRefKey(ref) {
82
86
  return "<empty-ref>";
83
87
  }
84
88
 
85
- // src/core/use-media-resolution.ts
89
+ // src/core/diagnostics.ts
86
90
  var import_react2 = require("react");
91
+ var LATCH_SLOT = /* @__PURE__ */ Symbol.for("ai-matrx.media.failure-latch");
92
+ function latch() {
93
+ const host = globalThis;
94
+ host[LATCH_SLOT] ??= /* @__PURE__ */ new Set();
95
+ return host[LATCH_SLOT];
96
+ }
97
+ function resetMediaFailureLatch() {
98
+ latch().clear();
99
+ }
100
+ function failureMediaIdentity(ref, src) {
101
+ if (ref && typeof ref === "object" && ref.file_id) return `file:${ref.file_id}`;
102
+ const raw = typeof ref === "string" ? ref : (ref && typeof ref === "object" ? ref.url ?? null : null) ?? src ?? null;
103
+ if (!raw) return "<unknown-media>";
104
+ if (raw.startsWith("blob:")) return "blob:object-url";
105
+ try {
106
+ const u = new URL(raw);
107
+ return `url:${u.origin}${u.pathname}`;
108
+ } catch {
109
+ const clean = raw.split(/[?#]/, 1)[0] ?? raw;
110
+ return `ref:${clean}`;
111
+ }
112
+ }
113
+ function emitMediaFailure(capture, info) {
114
+ const key = `${info.terminal ? "terminal" : "warning"}|${info.mediaRef}`;
115
+ const seen = latch();
116
+ if (seen.has(key)) return;
117
+ seen.add(key);
118
+ if (capture) {
119
+ try {
120
+ capture(info);
121
+ return;
122
+ } catch {
123
+ }
124
+ }
125
+ console.error(
126
+ "[@ai-matrx/media] media failure (no diagnostics sink captured it \u2014 bind MediaHostPorts.diagnostics.capture to your error system)",
127
+ info
128
+ );
129
+ }
130
+ function useMediaFailureCapture() {
131
+ const ports = useMediaHostPorts();
132
+ const capture = ports.diagnostics?.capture;
133
+ const captureRef = (0, import_react2.useRef)(capture);
134
+ captureRef.current = capture;
135
+ return (0, import_react2.useCallback)((info) => {
136
+ emitMediaFailure(captureRef.current, { source: "media", ...info });
137
+ }, []);
138
+ }
139
+
140
+ // src/core/use-media-resolution.ts
141
+ var import_react3 = require("react");
87
142
  function useMediaResolution(ref) {
88
143
  const client = useMediaClient();
144
+ const capture = useMediaFailureCapture();
89
145
  const refKey = stableRefKey(ref);
90
- return (0, import_react2.useMemo)(() => {
91
- if (!ref) return { resolution: null, status: "empty", reason: null };
146
+ const { state, failureMessage } = (0, import_react3.useMemo)(() => {
147
+ if (!ref) {
148
+ return {
149
+ state: { resolution: null, status: "empty", reason: null },
150
+ failureMessage: null
151
+ };
152
+ }
92
153
  try {
93
154
  const resolution = client.resolve(ref);
94
- if (!resolution) return { resolution: null, status: "empty", reason: null };
95
- return { resolution, status: "ready", reason: null };
155
+ if (!resolution) {
156
+ return {
157
+ state: { resolution: null, status: "empty", reason: null },
158
+ failureMessage: null
159
+ };
160
+ }
161
+ return {
162
+ state: { resolution, status: "ready", reason: null },
163
+ failureMessage: null
164
+ };
96
165
  } catch (err) {
97
166
  return {
98
- resolution: null,
99
- status: "unavailable",
100
- reason: client.classifyError(err)
167
+ state: {
168
+ resolution: null,
169
+ status: "unavailable",
170
+ reason: client.classifyError(err)
171
+ },
172
+ failureMessage: err instanceof Error ? err.message : String(err ?? "resolve() failed")
101
173
  };
102
174
  }
103
175
  }, [client, refKey]);
176
+ (0, import_react3.useEffect)(() => {
177
+ if (state.status !== "unavailable") return;
178
+ capture({
179
+ phase: "resolve",
180
+ mediaRef: failureMediaIdentity(ref ?? null),
181
+ terminal: true,
182
+ message: `resolve() refused (${state.reason ?? "unknown"}): ${failureMessage ?? "no error message"}`
183
+ });
184
+ }, [refKey, state.status]);
185
+ return state;
104
186
  }
105
187
 
106
188
  // src/core/use-media-load-recovery.ts
107
- var import_react3 = require("react");
189
+ var import_react4 = require("react");
108
190
 
109
191
  // src/types.ts
110
192
  function mintDurableSrc(url) {
@@ -112,26 +194,51 @@ function mintDurableSrc(url) {
112
194
  }
113
195
 
114
196
  // src/core/use-media-load-recovery.ts
197
+ function normalizeOutcome(outcome) {
198
+ return typeof outcome === "string" ? { verdict: outcome } : outcome;
199
+ }
200
+ var EPHEMERAL_SECRET_IDENTITY = "server:files-session-secret";
115
201
  function useMediaLoadRecovery(src, options) {
116
202
  const client = useMediaClient();
203
+ const capture = useMediaFailureCapture();
117
204
  const recoverable = options?.recoverable !== false;
118
205
  const onTerminal = options?.onTerminal;
119
- const [result, setResult] = (0, import_react3.useState)({ source: src, retryKey: 0, failed: false });
120
- const attemptRef = (0, import_react3.useRef)(0);
121
- (0, import_react3.useEffect)(() => {
206
+ const failureRef = options?.failureRef;
207
+ const [result, setResult] = (0, import_react4.useState)({ source: src, retryKey: 0, failed: false });
208
+ const attemptRef = (0, import_react4.useRef)(0);
209
+ (0, import_react4.useEffect)(() => {
122
210
  attemptRef.current = 0;
123
211
  }, [src]);
212
+ const identity = () => failureMediaIdentity(failureRef ?? null, src);
124
213
  const onLoadError = (event) => {
125
214
  if (!src || !recoverable) {
126
215
  setResult({ source: src, retryKey: 0, failed: true });
216
+ if (src) {
217
+ capture({
218
+ phase: "element-load",
219
+ mediaRef: identity(),
220
+ terminal: true,
221
+ retryOutcome: "not-attempted",
222
+ message: "media element failed to load and the source is unrecoverable (foreign/object URL \u2014 a session refresh cannot fix it)"
223
+ });
224
+ }
127
225
  onTerminal?.(event);
128
226
  return;
129
227
  }
130
228
  attemptRef.current += 1;
131
229
  const attempt = attemptRef.current;
132
230
  client.recoverLoadError(mintDurableSrc(src), attempt).then(
133
- (verdict) => {
134
- if (verdict === "retry") {
231
+ (outcome) => {
232
+ const report = normalizeOutcome(outcome);
233
+ if (report.secretSource === "ephemeral") {
234
+ capture({
235
+ phase: "session",
236
+ mediaRef: EPHEMERAL_SECRET_IDENTITY,
237
+ terminal: false,
238
+ message: "server cookie secret is ephemeral \u2014 private renders will break across restarts/instances (set a persistent files-session secret on the server)"
239
+ });
240
+ }
241
+ if (report.verdict === "retry") {
135
242
  setResult((current) => ({
136
243
  source: src,
137
244
  retryKey: current.source === src ? current.retryKey + 1 : 1,
@@ -139,11 +246,26 @@ function useMediaLoadRecovery(src, options) {
139
246
  }));
140
247
  } else {
141
248
  setResult({ source: src, retryKey: 0, failed: true });
249
+ capture({
250
+ phase: attempt > 1 ? "element-load" : "recovery",
251
+ mediaRef: identity(),
252
+ terminal: true,
253
+ retryOutcome: attempt > 1 ? "retried" : "not-attempted",
254
+ message: attempt > 1 ? "media element failed to load after the session-refresh retry" : "media load recovery declined on the first attempt (session refresh did not produce a usable session)",
255
+ ...report.status !== void 0 ? { status: report.status } : {}
256
+ });
142
257
  onTerminal?.(event);
143
258
  }
144
259
  },
145
- () => {
260
+ (err) => {
146
261
  setResult({ source: src, retryKey: 0, failed: true });
262
+ capture({
263
+ phase: "session",
264
+ mediaRef: identity(),
265
+ terminal: true,
266
+ retryOutcome: "recovery-failed",
267
+ message: `session recovery failed: ${err instanceof Error ? err.message : String(err ?? "unknown error")}`
268
+ });
147
269
  onTerminal?.(event);
148
270
  }
149
271
  );
@@ -156,13 +278,21 @@ function useMediaLoadRecovery(src, options) {
156
278
  }
157
279
 
158
280
  // src/core/use-media-blob.ts
159
- var import_react4 = require("react");
281
+ var import_react5 = require("react");
282
+ function statusOf(err) {
283
+ if (err && typeof err === "object") {
284
+ const candidate = err.status ?? err.statusCode;
285
+ if (typeof candidate === "number") return candidate;
286
+ }
287
+ return void 0;
288
+ }
160
289
  function useMediaBlob(ref) {
161
290
  const client = useMediaClient();
291
+ const capture = useMediaFailureCapture();
162
292
  const refKey = stableRefKey(ref);
163
- const [state, setState] = (0, import_react4.useState)({ url: null, blob: null, loading: !!ref, error: null });
164
- const [retryToken, setRetryToken] = (0, import_react4.useState)(0);
165
- (0, import_react4.useEffect)(() => {
293
+ const [state, setState] = (0, import_react5.useState)({ url: null, blob: null, loading: !!ref, error: null });
294
+ const [retryToken, setRetryToken] = (0, import_react5.useState)(0);
295
+ (0, import_react5.useEffect)(() => {
166
296
  if (!ref) {
167
297
  setState({ url: null, blob: null, loading: false, error: null });
168
298
  return void 0;
@@ -183,6 +313,14 @@ function useMediaBlob(ref) {
183
313
  if (cancelled) return;
184
314
  const message = err instanceof Error ? err.message : String(err ?? "Failed to load file");
185
315
  setState({ url: null, blob: null, loading: false, error: message });
316
+ const status = statusOf(err);
317
+ capture({
318
+ phase: "blob",
319
+ mediaRef: failureMediaIdentity(ref),
320
+ terminal: true,
321
+ message: `getBlob() failed: ${message}`,
322
+ ...status !== void 0 ? { status } : {}
323
+ });
186
324
  }
187
325
  );
188
326
  return () => {
@@ -197,17 +335,17 @@ function useMediaBlob(ref) {
197
335
  }
198
336
 
199
337
  // src/core/use-media-upload.ts
200
- var import_react5 = require("react");
338
+ var import_react6 = require("react");
201
339
  var entrySeq = 0;
202
340
  function useMediaUpload() {
203
341
  const client = useMediaClient();
204
- const [uploading, setUploading] = (0, import_react5.useState)(false);
205
- const [progress, setProgress] = (0, import_react5.useState)(null);
206
- const [result, setResult] = (0, import_react5.useState)(null);
207
- const [error, setError] = (0, import_react5.useState)(null);
208
- const [entries, setEntries] = (0, import_react5.useState)([]);
209
- const activeCount = (0, import_react5.useRef)(0);
210
- const patchEntry = (0, import_react5.useCallback)(
342
+ const [uploading, setUploading] = (0, import_react6.useState)(false);
343
+ const [progress, setProgress] = (0, import_react6.useState)(null);
344
+ const [result, setResult] = (0, import_react6.useState)(null);
345
+ const [error, setError] = (0, import_react6.useState)(null);
346
+ const [entries, setEntries] = (0, import_react6.useState)([]);
347
+ const activeCount = (0, import_react6.useRef)(0);
348
+ const patchEntry = (0, import_react6.useCallback)(
211
349
  (id, patch) => {
212
350
  setEntries(
213
351
  (current) => current.map((e) => e.id === id ? { ...e, ...patch } : e)
@@ -215,7 +353,7 @@ function useMediaUpload() {
215
353
  },
216
354
  []
217
355
  );
218
- const upload = (0, import_react5.useCallback)(
356
+ const upload = (0, import_react6.useCallback)(
219
357
  async (file, opts = {}) => {
220
358
  const id = `up-${++entrySeq}`;
221
359
  const fileName = opts.fileName ?? (file instanceof File ? file.name : "file");
@@ -269,7 +407,7 @@ function useMediaUpload() {
269
407
  },
270
408
  [client, patchEntry]
271
409
  );
272
- const uploadMany = (0, import_react5.useCallback)(
410
+ const uploadMany = (0, import_react6.useCallback)(
273
411
  async (files, opts = {}) => {
274
412
  if (client.uploadMany) {
275
413
  activeCount.current += 1;
@@ -298,10 +436,10 @@ function useMediaUpload() {
298
436
  },
299
437
  [client, upload]
300
438
  );
301
- const clearEntry = (0, import_react5.useCallback)((id) => {
439
+ const clearEntry = (0, import_react6.useCallback)((id) => {
302
440
  setEntries((current) => current.filter((e) => e.id !== id));
303
441
  }, []);
304
- const reset = (0, import_react5.useCallback)(() => {
442
+ const reset = (0, import_react6.useCallback)(() => {
305
443
  setResult(null);
306
444
  setError(null);
307
445
  setProgress(null);
@@ -321,12 +459,12 @@ function useMediaUpload() {
321
459
  }
322
460
 
323
461
  // src/core/use-thumbnail-source.ts
324
- var import_react6 = require("react");
462
+ var import_react7 = require("react");
325
463
  function useThumbnailSource(ref, options = {}) {
326
464
  const { thumbnailUrl, allowSourceFallback = true } = options;
327
465
  const client = useMediaClient();
328
466
  const { resolution } = useMediaResolution(ref);
329
- const explicitThumb = (0, import_react6.useMemo)(() => {
467
+ const explicitThumb = (0, import_react7.useMemo)(() => {
330
468
  if (!thumbnailUrl) return null;
331
469
  try {
332
470
  return client.resolve(thumbnailUrl);
@@ -393,19 +531,19 @@ function useThumbnailSource(ref, options = {}) {
393
531
  }
394
532
 
395
533
  // src/core/use-media-actions.ts
396
- var import_react7 = require("react");
534
+ var import_react8 = require("react");
397
535
  function useMediaActions(ref, options = {}) {
398
536
  const ports = useMediaHostPorts();
399
537
  const { resolution } = useMediaResolution(ref);
400
- const [busy, setBusy] = (0, import_react7.useState)(null);
401
- const [lastError, setLastError] = (0, import_react7.useState)(null);
538
+ const [busy, setBusy] = (0, import_react8.useState)(null);
539
+ const [lastError, setLastError] = (0, import_react8.useState)(null);
402
540
  const actions = ports.actions;
403
541
  const { fileName, alt } = options;
404
- const context = (0, import_react7.useMemo)(() => {
542
+ const context = (0, import_react8.useMemo)(() => {
405
543
  if (!ref) return null;
406
544
  return { ref, resolution, fileName, alt };
407
545
  }, [ref, resolution, fileName, alt]);
408
- const isAvailable = (0, import_react7.useCallback)(
546
+ const isAvailable = (0, import_react8.useCallback)(
409
547
  (kind) => {
410
548
  if (!context || !actions) return false;
411
549
  if (kind === "share") return Boolean(actions.share || actions.SharePopover);
@@ -413,7 +551,7 @@ function useMediaActions(ref, options = {}) {
413
551
  },
414
552
  [context, actions]
415
553
  );
416
- const run = (0, import_react7.useCallback)(
554
+ const run = (0, import_react8.useCallback)(
417
555
  async (kind) => {
418
556
  if (!context || !actions) return;
419
557
  const handler = actions[kind];