@ai-matrx/media 0.2.3 → 0.4.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/dist/core.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
- import { MediaClient, MediaHostPorts, MediaRefLike, MediaResolution, MediaUnavailableReason, DurableSrc, MediaActionContext, MediaActionKind, MediaSlotComponent, MediaSharePopoverProps } from './index.js';
3
+ import { MediaClient, MediaHostPorts, MediaRefLike, MediaFailureInfo, MediaResolution, MediaUnavailableReason, DurableSrc, MediaActionContext, MediaActionKind, MediaSlotComponent, MediaSharePopoverProps } from './index.js';
4
4
  export { M as MediaUploadEntry, a as MediaUploadEntryStatus, b as MediaUploadProgress, U as UseMediaUploadResult, u as useMediaUpload } from './use-media-upload-Dg2HSvTq.js';
5
5
 
6
6
  interface MediaProviderProps {
@@ -29,6 +29,58 @@ declare function useMediaHostPorts(): MediaHostPorts;
29
29
 
30
30
  declare function stableRefKey(ref: MediaRefLike | null | undefined): string;
31
31
 
32
+ /**
33
+ * src/core/diagnostics.ts — the terminal-failure capture machinery.
34
+ *
35
+ * Born from the 2026-08-30 prod outage: server-side cookie-secret drift
36
+ * killed every private image, the package rendered its honest "unavailable"
37
+ * panel — and nothing reached the host's Error Inspector or error DB,
38
+ * because the package had no diagnostics port and the host cannot capture
39
+ * what it never learns about. Per ruling C22 the capture logic lives HERE,
40
+ * so every consuming repo inherits it.
41
+ *
42
+ * The contract:
43
+ *
44
+ * - every terminal failure path emits exactly ONE {@link MediaFailureInfo}
45
+ * per media identity (latched — re-renders, remounts and the unavailable
46
+ * panel backstop never spam);
47
+ * - a bound `MediaHostPorts.diagnostics.capture` receives the payload;
48
+ * WITHOUT one (or if it throws) the package `console.error`s the same
49
+ * structured payload — the loud default. A media failure is NEVER silent;
50
+ * - the payload never carries a signed URL: identity is the file id or a
51
+ * query-stripped URL key ({@link failureMediaIdentity}).
52
+ *
53
+ * The latch lives on `globalThis` under a `Symbol.for` slot (same reason as
54
+ * context.ts: with `splitting: false` + dual formats, module-level state
55
+ * silently duplicates across bundle/loader graphs).
56
+ */
57
+
58
+ /**
59
+ * Clear the once-per-failure latch. For tests, and for hosts at auth
60
+ * boundaries (a sign-in can genuinely fix a previously-terminal failure, and
61
+ * a recurrence after it deserves a fresh capture).
62
+ */
63
+ declare function resetMediaFailureLatch(): void;
64
+ /**
65
+ * Durable diagnostics identity for a media reference. NEVER a signed URL:
66
+ * file ids win, and any URL is stripped to origin + path (signatures ride
67
+ * query strings).
68
+ */
69
+ declare function failureMediaIdentity(ref: MediaRefLike | null | undefined, src?: string | null): string;
70
+ /**
71
+ * Emit one media-failure capture through the host sink, or the loud
72
+ * console.error default when no sink is bound (or the sink throws).
73
+ * Latched per (terminal|warning, mediaRef) — one capture per failure,
74
+ * however many renders, hooks, or backstops observe it.
75
+ */
76
+ declare function emitMediaFailure(capture: ((info: MediaFailureInfo) => void) | undefined, info: MediaFailureInfo): void;
77
+ /**
78
+ * Hook form: a stable capture function wired to the host ports. Every core
79
+ * hook and the unavailable-panel backstop route through this — the phase
80
+ * vocabulary and the latch stay in ONE place.
81
+ */
82
+ declare function useMediaFailureCapture(): (info: Omit<MediaFailureInfo, "source">) => void;
83
+
32
84
  /**
33
85
  * src/core/use-media-resolution.ts — ref → durable resolution, as state.
34
86
  *
@@ -36,8 +88,10 @@ declare function stableRefKey(ref: MediaRefLike | null | undefined): string;
36
88
  * even a caller-supplied URL string is classified, never trusted. Law 15 of
37
89
  * the handler (unavailable is a rendered state): a throwing `resolve()` is
38
90
  * classified via `classifyError` and returned as typed state — never
39
- * re-thrown, never console.error'd here (the client owns loud recovery for
40
- * unexpected failures via its own diagnostics sink).
91
+ * re-thrown. It IS however a terminal failure, so it also emits exactly one
92
+ * `MediaFailureInfo` (phase "resolve") through the diagnostics machinery —
93
+ * the 2026-08-30 outage rule: an unavailable render is honest to the USER but
94
+ * must never be silent to the SYSTEM.
41
95
  */
42
96
 
43
97
  interface MediaResolutionState {
@@ -65,6 +119,26 @@ declare function useMediaResolution(ref: MediaRefLike | null | undefined): Media
65
119
  * `recoverable: false` resolutions (foreign URLs, blob: object URLs) skip the
66
120
  * client and fail straight through — a session refresh can't resurrect
67
121
  * someone else's link rot.
122
+ *
123
+ * Every terminal outcome emits exactly one `MediaFailureInfo` through the
124
+ * diagnostics machinery (the 2026-08-30 outage rule):
125
+ *
126
+ * - element error, recovery impossible → phase "element-load",
127
+ * retryOutcome "not-attempted"
128
+ * - terminal verdict after a granted retry → phase "element-load",
129
+ * retryOutcome "retried"
130
+ * - terminal verdict on the FIRST attempt → phase "recovery",
131
+ * retryOutcome "not-attempted"
132
+ * (the recovery machinery itself declined — usually the session mint
133
+ * came back unusable)
134
+ * - `recoverLoadError` rejected → phase "session",
135
+ * retryOutcome "recovery-failed"
136
+ *
137
+ * A structured `MediaRecoveryReport` verdict may additionally carry the
138
+ * `/files/session` mint's `secret_source`; `"ephemeral"` emits a NON-terminal
139
+ * warning ("private renders will break across restarts/instances") the moment
140
+ * the client sees it — before the outage, not after. The bare string verdict
141
+ * stays fully supported; the field is tolerated absent.
68
142
  */
69
143
 
70
144
  interface MediaLoadRecovery {
@@ -82,6 +156,12 @@ declare function useMediaLoadRecovery(src: DurableSrc | string | null, options?:
82
156
  recoverable?: boolean | undefined;
83
157
  /** Fires once recovery is exhausted, with the original element event. */
84
158
  onTerminal?: ((event?: unknown) => void) | undefined;
159
+ /**
160
+ * Durable identity of the media being recovered, for diagnostics —
161
+ * failures are reported against the file id / ref key, never the URL.
162
+ * Falls back to the query-stripped src when absent.
163
+ */
164
+ failureRef?: MediaRefLike | null | undefined;
85
165
  }): MediaLoadRecovery;
86
166
 
87
167
  /**
@@ -211,4 +291,4 @@ declare function getFileKindDetails(fileName: string, mimeType?: string | null):
211
291
  /** Folder icon details (open picks the open-folder glyph). */
212
292
  declare function getFolderKindDetails(open?: boolean): FileKindDetails;
213
293
 
214
- export { type FileCategory, type FileIconName, type FileKindDetails, type MediaActionsState, type MediaBlobState, type MediaLoadRecovery, MediaProvider, type MediaProviderProps, type MediaResolutionState, type ThumbnailMode, type ThumbnailSourceState, type UseMediaActionsOptions, type UseThumbnailSourceOptions, getFileKindDetails, getFolderKindDetails, stableRefKey, useMediaActions, useMediaBlob, useMediaClient, useMediaHostPorts, useMediaLoadRecovery, useMediaResolution, useThumbnailSource };
294
+ export { type FileCategory, type FileIconName, type FileKindDetails, type MediaActionsState, type MediaBlobState, type MediaLoadRecovery, MediaProvider, type MediaProviderProps, type MediaResolutionState, type ThumbnailMode, type ThumbnailSourceState, type UseMediaActionsOptions, type UseThumbnailSourceOptions, emitMediaFailure, failureMediaIdentity, getFileKindDetails, getFolderKindDetails, resetMediaFailureLatch, stableRefKey, useMediaActions, useMediaBlob, useMediaClient, useMediaFailureCapture, useMediaHostPorts, useMediaLoadRecovery, useMediaResolution, useThumbnailSource };
package/dist/core.js CHANGED
@@ -50,29 +50,107 @@ function stableRefKey(ref) {
50
50
  return "<empty-ref>";
51
51
  }
52
52
 
53
+ // src/core/diagnostics.ts
54
+ import { useCallback, useRef } from "react";
55
+ var LATCH_SLOT = /* @__PURE__ */ Symbol.for("ai-matrx.media.failure-latch");
56
+ function latch() {
57
+ const host = globalThis;
58
+ host[LATCH_SLOT] ??= /* @__PURE__ */ new Set();
59
+ return host[LATCH_SLOT];
60
+ }
61
+ function resetMediaFailureLatch() {
62
+ latch().clear();
63
+ }
64
+ function failureMediaIdentity(ref, src) {
65
+ if (ref && typeof ref === "object" && ref.file_id) return `file:${ref.file_id}`;
66
+ const raw = typeof ref === "string" ? ref : (ref && typeof ref === "object" ? ref.url ?? null : null) ?? src ?? null;
67
+ if (!raw) return "<unknown-media>";
68
+ if (raw.startsWith("blob:")) return "blob:object-url";
69
+ try {
70
+ const u = new URL(raw);
71
+ return `url:${u.origin}${u.pathname}`;
72
+ } catch {
73
+ const clean = raw.split(/[?#]/, 1)[0] ?? raw;
74
+ return `ref:${clean}`;
75
+ }
76
+ }
77
+ function emitMediaFailure(capture, info) {
78
+ const key = `${info.terminal ? "terminal" : "warning"}|${info.mediaRef}`;
79
+ const seen = latch();
80
+ if (seen.has(key)) return;
81
+ seen.add(key);
82
+ if (capture) {
83
+ try {
84
+ capture(info);
85
+ return;
86
+ } catch {
87
+ }
88
+ }
89
+ console.error(
90
+ "[@ai-matrx/media] media failure (no diagnostics sink captured it \u2014 bind MediaHostPorts.diagnostics.capture to your error system)",
91
+ info
92
+ );
93
+ }
94
+ function useMediaFailureCapture() {
95
+ const ports = useMediaHostPorts();
96
+ const capture = ports.diagnostics?.capture;
97
+ const captureRef = useRef(capture);
98
+ captureRef.current = capture;
99
+ return useCallback((info) => {
100
+ emitMediaFailure(captureRef.current, { source: "media", ...info });
101
+ }, []);
102
+ }
103
+
53
104
  // src/core/use-media-resolution.ts
54
- import { useMemo } from "react";
105
+ import { useEffect, useMemo } from "react";
55
106
  function useMediaResolution(ref) {
56
107
  const client = useMediaClient();
108
+ const capture = useMediaFailureCapture();
57
109
  const refKey = stableRefKey(ref);
58
- return useMemo(() => {
59
- if (!ref) return { resolution: null, status: "empty", reason: null };
110
+ const { state, failureMessage } = useMemo(() => {
111
+ if (!ref) {
112
+ return {
113
+ state: { resolution: null, status: "empty", reason: null },
114
+ failureMessage: null
115
+ };
116
+ }
60
117
  try {
61
118
  const resolution = client.resolve(ref);
62
- if (!resolution) return { resolution: null, status: "empty", reason: null };
63
- return { resolution, status: "ready", reason: null };
119
+ if (!resolution) {
120
+ return {
121
+ state: { resolution: null, status: "empty", reason: null },
122
+ failureMessage: null
123
+ };
124
+ }
125
+ return {
126
+ state: { resolution, status: "ready", reason: null },
127
+ failureMessage: null
128
+ };
64
129
  } catch (err) {
65
130
  return {
66
- resolution: null,
67
- status: "unavailable",
68
- reason: client.classifyError(err)
131
+ state: {
132
+ resolution: null,
133
+ status: "unavailable",
134
+ reason: client.classifyError(err)
135
+ },
136
+ failureMessage: err instanceof Error ? err.message : String(err ?? "resolve() failed")
69
137
  };
70
138
  }
71
139
  }, [client, refKey]);
140
+ useEffect(() => {
141
+ if (state.status !== "unavailable") return;
142
+ capture({
143
+ phase: "resolve",
144
+ mediaRef: failureMediaIdentity(ref ?? null),
145
+ terminal: true,
146
+ message: `resolve() refused (${state.reason ?? "unknown"}): ${failureMessage ?? "no error message"}`
147
+ });
148
+ }, [refKey, state.status]);
149
+ return state;
72
150
  }
73
151
 
74
152
  // src/core/use-media-load-recovery.ts
75
- import { useEffect, useRef, useState } from "react";
153
+ import { useEffect as useEffect2, useRef as useRef2, useState } from "react";
76
154
 
77
155
  // src/types.ts
78
156
  function mintDurableSrc(url) {
@@ -80,26 +158,51 @@ function mintDurableSrc(url) {
80
158
  }
81
159
 
82
160
  // src/core/use-media-load-recovery.ts
161
+ function normalizeOutcome(outcome) {
162
+ return typeof outcome === "string" ? { verdict: outcome } : outcome;
163
+ }
164
+ var EPHEMERAL_SECRET_IDENTITY = "server:files-session-secret";
83
165
  function useMediaLoadRecovery(src, options) {
84
166
  const client = useMediaClient();
167
+ const capture = useMediaFailureCapture();
85
168
  const recoverable = options?.recoverable !== false;
86
169
  const onTerminal = options?.onTerminal;
170
+ const failureRef = options?.failureRef;
87
171
  const [result, setResult] = useState({ source: src, retryKey: 0, failed: false });
88
- const attemptRef = useRef(0);
89
- useEffect(() => {
172
+ const attemptRef = useRef2(0);
173
+ useEffect2(() => {
90
174
  attemptRef.current = 0;
91
175
  }, [src]);
176
+ const identity = () => failureMediaIdentity(failureRef ?? null, src);
92
177
  const onLoadError = (event) => {
93
178
  if (!src || !recoverable) {
94
179
  setResult({ source: src, retryKey: 0, failed: true });
180
+ if (src) {
181
+ capture({
182
+ phase: "element-load",
183
+ mediaRef: identity(),
184
+ terminal: true,
185
+ retryOutcome: "not-attempted",
186
+ message: "media element failed to load and the source is unrecoverable (foreign/object URL \u2014 a session refresh cannot fix it)"
187
+ });
188
+ }
95
189
  onTerminal?.(event);
96
190
  return;
97
191
  }
98
192
  attemptRef.current += 1;
99
193
  const attempt = attemptRef.current;
100
194
  client.recoverLoadError(mintDurableSrc(src), attempt).then(
101
- (verdict) => {
102
- if (verdict === "retry") {
195
+ (outcome) => {
196
+ const report = normalizeOutcome(outcome);
197
+ if (report.secretSource === "ephemeral") {
198
+ capture({
199
+ phase: "session",
200
+ mediaRef: EPHEMERAL_SECRET_IDENTITY,
201
+ terminal: false,
202
+ message: "server cookie secret is ephemeral \u2014 private renders will break across restarts/instances (set a persistent files-session secret on the server)"
203
+ });
204
+ }
205
+ if (report.verdict === "retry") {
103
206
  setResult((current) => ({
104
207
  source: src,
105
208
  retryKey: current.source === src ? current.retryKey + 1 : 1,
@@ -107,11 +210,26 @@ function useMediaLoadRecovery(src, options) {
107
210
  }));
108
211
  } else {
109
212
  setResult({ source: src, retryKey: 0, failed: true });
213
+ capture({
214
+ phase: attempt > 1 ? "element-load" : "recovery",
215
+ mediaRef: identity(),
216
+ terminal: true,
217
+ retryOutcome: attempt > 1 ? "retried" : "not-attempted",
218
+ 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)",
219
+ ...report.status !== void 0 ? { status: report.status } : {}
220
+ });
110
221
  onTerminal?.(event);
111
222
  }
112
223
  },
113
- () => {
224
+ (err) => {
114
225
  setResult({ source: src, retryKey: 0, failed: true });
226
+ capture({
227
+ phase: "session",
228
+ mediaRef: identity(),
229
+ terminal: true,
230
+ retryOutcome: "recovery-failed",
231
+ message: `session recovery failed: ${err instanceof Error ? err.message : String(err ?? "unknown error")}`
232
+ });
115
233
  onTerminal?.(event);
116
234
  }
117
235
  );
@@ -124,13 +242,21 @@ function useMediaLoadRecovery(src, options) {
124
242
  }
125
243
 
126
244
  // src/core/use-media-blob.ts
127
- import { useEffect as useEffect2, useState as useState2 } from "react";
245
+ import { useEffect as useEffect3, useState as useState2 } from "react";
246
+ function statusOf(err) {
247
+ if (err && typeof err === "object") {
248
+ const candidate = err.status ?? err.statusCode;
249
+ if (typeof candidate === "number") return candidate;
250
+ }
251
+ return void 0;
252
+ }
128
253
  function useMediaBlob(ref) {
129
254
  const client = useMediaClient();
255
+ const capture = useMediaFailureCapture();
130
256
  const refKey = stableRefKey(ref);
131
257
  const [state, setState] = useState2({ url: null, blob: null, loading: !!ref, error: null });
132
258
  const [retryToken, setRetryToken] = useState2(0);
133
- useEffect2(() => {
259
+ useEffect3(() => {
134
260
  if (!ref) {
135
261
  setState({ url: null, blob: null, loading: false, error: null });
136
262
  return void 0;
@@ -151,6 +277,14 @@ function useMediaBlob(ref) {
151
277
  if (cancelled) return;
152
278
  const message = err instanceof Error ? err.message : String(err ?? "Failed to load file");
153
279
  setState({ url: null, blob: null, loading: false, error: message });
280
+ const status = statusOf(err);
281
+ capture({
282
+ phase: "blob",
283
+ mediaRef: failureMediaIdentity(ref),
284
+ terminal: true,
285
+ message: `getBlob() failed: ${message}`,
286
+ ...status !== void 0 ? { status } : {}
287
+ });
154
288
  }
155
289
  );
156
290
  return () => {
@@ -165,7 +299,7 @@ function useMediaBlob(ref) {
165
299
  }
166
300
 
167
301
  // src/core/use-media-upload.ts
168
- import { useCallback, useRef as useRef2, useState as useState3 } from "react";
302
+ import { useCallback as useCallback2, useRef as useRef3, useState as useState3 } from "react";
169
303
  var entrySeq = 0;
170
304
  function useMediaUpload() {
171
305
  const client = useMediaClient();
@@ -174,8 +308,8 @@ function useMediaUpload() {
174
308
  const [result, setResult] = useState3(null);
175
309
  const [error, setError] = useState3(null);
176
310
  const [entries, setEntries] = useState3([]);
177
- const activeCount = useRef2(0);
178
- const patchEntry = useCallback(
311
+ const activeCount = useRef3(0);
312
+ const patchEntry = useCallback2(
179
313
  (id, patch) => {
180
314
  setEntries(
181
315
  (current) => current.map((e) => e.id === id ? { ...e, ...patch } : e)
@@ -183,7 +317,7 @@ function useMediaUpload() {
183
317
  },
184
318
  []
185
319
  );
186
- const upload = useCallback(
320
+ const upload = useCallback2(
187
321
  async (file, opts = {}) => {
188
322
  const id = `up-${++entrySeq}`;
189
323
  const fileName = opts.fileName ?? (file instanceof File ? file.name : "file");
@@ -237,7 +371,7 @@ function useMediaUpload() {
237
371
  },
238
372
  [client, patchEntry]
239
373
  );
240
- const uploadMany = useCallback(
374
+ const uploadMany = useCallback2(
241
375
  async (files, opts = {}) => {
242
376
  if (client.uploadMany) {
243
377
  activeCount.current += 1;
@@ -266,10 +400,10 @@ function useMediaUpload() {
266
400
  },
267
401
  [client, upload]
268
402
  );
269
- const clearEntry = useCallback((id) => {
403
+ const clearEntry = useCallback2((id) => {
270
404
  setEntries((current) => current.filter((e) => e.id !== id));
271
405
  }, []);
272
- const reset = useCallback(() => {
406
+ const reset = useCallback2(() => {
273
407
  setResult(null);
274
408
  setError(null);
275
409
  setProgress(null);
@@ -361,7 +495,7 @@ function useThumbnailSource(ref, options = {}) {
361
495
  }
362
496
 
363
497
  // src/core/use-media-actions.ts
364
- import { useCallback as useCallback2, useMemo as useMemo3, useState as useState4 } from "react";
498
+ import { useCallback as useCallback3, useMemo as useMemo3, useState as useState4 } from "react";
365
499
  function useMediaActions(ref, options = {}) {
366
500
  const ports = useMediaHostPorts();
367
501
  const { resolution } = useMediaResolution(ref);
@@ -373,7 +507,7 @@ function useMediaActions(ref, options = {}) {
373
507
  if (!ref) return null;
374
508
  return { ref, resolution, fileName, alt };
375
509
  }, [ref, resolution, fileName, alt]);
376
- const isAvailable = useCallback2(
510
+ const isAvailable = useCallback3(
377
511
  (kind) => {
378
512
  if (!context || !actions) return false;
379
513
  if (kind === "share") return Boolean(actions.share || actions.SharePopover);
@@ -381,7 +515,7 @@ function useMediaActions(ref, options = {}) {
381
515
  },
382
516
  [context, actions]
383
517
  );
384
- const run = useCallback2(
518
+ const run = useCallback3(
385
519
  async (kind) => {
386
520
  if (!context || !actions) return;
387
521
  const handler = actions[kind];
@@ -544,12 +678,16 @@ function getFolderKindDetails(open = false) {
544
678
  }
545
679
  export {
546
680
  MediaProvider,
681
+ emitMediaFailure,
682
+ failureMediaIdentity,
547
683
  getFileKindDetails,
548
684
  getFolderKindDetails,
685
+ resetMediaFailureLatch,
549
686
  stableRefKey,
550
687
  useMediaActions,
551
688
  useMediaBlob,
552
689
  useMediaClient,
690
+ useMediaFailureCapture,
553
691
  useMediaHostPorts,
554
692
  useMediaLoadRecovery,
555
693
  useMediaResolution,