useCameraStreamToken.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. import { useEffect, useRef } from 'react';
  2. import { useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { api, setStreamToken, getStreamToken, withStreamToken } from '../api/client';
  4. import { useAuth } from '../contexts/AuthContext';
  5. /**
  6. * Walks the DOM and updates every <img>/<video> pointing at /api/v1/ so its
  7. * src carries the current stream token. Exported for unit testing; called
  8. * from useStreamTokenSync when the token arrives after first render.
  9. */
  10. export function rewriteMediaSrcWithToken(root: ParentNode, token: string): number {
  11. const tokenParam = `token=${encodeURIComponent(token)}`;
  12. let updated = 0;
  13. root
  14. .querySelectorAll<HTMLImageElement | HTMLVideoElement>(
  15. 'img[src*="/api/v1/"], video[src*="/api/v1/"]'
  16. )
  17. .forEach((el) => {
  18. const src = el.getAttribute('src') || '';
  19. if (src.includes(tokenParam)) return;
  20. const withoutToken = src.replace(/([?&])token=[^&]*(&|$)/, (_m, pre, post) =>
  21. post === '&' ? pre : pre === '?' ? '' : ''
  22. );
  23. const sep = withoutToken.includes('?') ? '&' : '?';
  24. el.src = `${withoutToken}${sep}${tokenParam}`;
  25. updated += 1;
  26. });
  27. return updated;
  28. }
  29. /**
  30. * Fetches and caches a stream token for <img>/<video> src URLs.
  31. * Stores the token globally via setStreamToken() so URL generators
  32. * in client.ts can use withStreamToken() automatically.
  33. *
  34. * Also listens for global image load errors on token-protected URLs
  35. * and automatically refreshes the token (e.g., after backend restart
  36. * invalidates in-memory tokens).
  37. *
  38. * Mount this hook once near the app root (e.g., in App.tsx or a layout component).
  39. * Components that need token-protected URLs can import withStreamToken directly.
  40. */
  41. export function useStreamTokenSync() {
  42. const { authEnabled, user, loading: authLoading } = useAuth();
  43. const queryClient = useQueryClient();
  44. const refreshingRef = useRef(false);
  45. // Key the token by user id so a login/logout invalidates the cache
  46. // automatically — otherwise a failed anonymous fetch on the login page
  47. // would be cached and never retried after sign-in.
  48. //
  49. // Race-aware gate (same shape as ColorCatalogProvider): wait for
  50. // ``checkAuthStatus`` to finish before deciding whether to fetch.
  51. // The previous form ``authEnabled ? !!user : true`` evaluated to
  52. // ``true`` on first render because ``authEnabled`` defaults to false,
  53. // firing a 401 POST on the login page before AuthContext had a chance
  54. // to settle on ``authEnabled=true, user=null``.
  55. const { data } = useQuery({
  56. queryKey: ['camera-stream-token', user?.id ?? null],
  57. queryFn: () => api.getCameraStreamToken(),
  58. enabled: !authLoading && (!authEnabled || user !== null),
  59. staleTime: 50 * 60 * 1000, // refresh at 50 min (tokens expire at 60)
  60. refetchInterval: 50 * 60 * 1000,
  61. });
  62. useEffect(() => {
  63. const newToken = data?.token ?? null;
  64. setStreamToken(newToken);
  65. // Images/videos that rendered before the token arrived have src URLs
  66. // without ?token=…; update them in place so they reload with auth.
  67. if (newToken) {
  68. rewriteMediaSrcWithToken(document, newToken);
  69. }
  70. return () => setStreamToken(null);
  71. }, [data?.token]);
  72. // Listen for image/video load errors on token-protected URLs.
  73. // When the backend restarts, in-memory stream tokens are lost and all
  74. // thumbnail/stream requests return 401. This handler detects that and
  75. // forces a token refresh so images recover without a page reload.
  76. useEffect(() => {
  77. if (!authEnabled) return;
  78. const handleError = (event: Event) => {
  79. const el = event.target;
  80. if (!(el instanceof HTMLImageElement || el instanceof HTMLVideoElement)) return;
  81. const src = el.src || '';
  82. const token = getStreamToken();
  83. if (!token || !src.includes(`token=${encodeURIComponent(token)}`)) return;
  84. // This image/video used our stream token and failed — token likely invalid
  85. if (refreshingRef.current) return;
  86. refreshingRef.current = true;
  87. queryClient.invalidateQueries({ queryKey: ['camera-stream-token'] });
  88. // Reset after a delay so future errors can trigger another refresh
  89. setTimeout(() => {
  90. refreshingRef.current = false;
  91. }, 5000);
  92. };
  93. // Use capture phase to catch errors before they're swallowed
  94. document.addEventListener('error', handleError, true);
  95. return () => document.removeEventListener('error', handleError, true);
  96. }, [authEnabled, queryClient]);
  97. }
  98. /**
  99. * Hook for components that need to wrap URLs with the stream token.
  100. * Returns a withToken function that appends ?token=xxx when auth is enabled.
  101. */
  102. export function useCameraStreamToken() {
  103. return { withToken: withStreamToken };
  104. }