diff --git a/apps/web/composables/useApi.ts b/apps/web/composables/useApi.ts new file mode 100644 index 0000000..ff2a476 --- /dev/null +++ b/apps/web/composables/useApi.ts @@ -0,0 +1,43 @@ +/** + * The one way this app calls `continuum-backend`. + * + * Auth is Supabase throughout: the backend verifies access tokens + * asymmetrically against the project's published JWKS and holds no signing + * key of its own (`continuum-backend/src/auth/verifier.ts`), and + * `requireUser` wants that token in `Authorization: Bearer`. So this attaches + * it, and that is the whole of the client's side of authentication. + * + * What this replaces: `useRequestHeaders(["cookie"])` on each call site. + * Forwarding a cookie is what you do when a Nuxt *server* is proxying a + * browser's request, and this app sets `ssr: false` so that it can ship the + * same static bundle to the browser and to the Tauri shell - there is no + * server to forward anything, so the header arrived empty and every call got + * a 401. The token was already to hand the whole time; the telemetry store + * has been reading it for its socket. + * + * The token is read inside the fetcher rather than captured when this is + * called, because supabase-js refreshes the session in the background about + * once an hour. A token captured at setup would work until it quietly + * stopped. + */ +export function useApiFetch() { + const config = useRuntimeConfig(); + const session = useSupabaseSession(); + + return (path: string, options: Parameters[1] = {}) => { + const token = session.value?.access_token; + + return $fetch(`${config.public.apiBase}${path}`, { + ...options, + headers: { + // Omitted rather than sent empty when there is no session. An empty + // Bearer is a malformed credential the backend rejects with a + // message about the header's shape, which reads like a client bug; + // no header at all is the honest "not signed in". The Supabase + // module's own redirect is what handles getting the user a session. + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options?.headers, + }, + }); + }; +} diff --git a/apps/web/composables/usePrinterStream.ts b/apps/web/composables/usePrinterStream.ts index 50f64ad..7b9734d 100644 --- a/apps/web/composables/usePrinterStream.ts +++ b/apps/web/composables/usePrinterStream.ts @@ -21,7 +21,8 @@ export interface PrinterStream { * in: the caller needs one of these per printer, discovered from a list that * itself arrives asynchronously, and Nuxt's data composables may only be * called while a component is setting up. A loop over a resolved list is not - * that. + * that. It takes the fetcher as an argument for the same reason - the caller + * builds it in setup, where `useApiFetch` is valid. * * **Absence is the normal case, not an error.** Only Bambu's X1/H2 class * publishes a camera URL a gateway can restream; a P1 or A1 reports that it @@ -35,12 +36,11 @@ export interface PrinterStream { * for anyone else. */ export async function fetchPrinterStream( - apiBase: string, + apiFetch: ReturnType, printerId: string, - headers: Record, ): Promise { try { - return await $fetch(`${apiBase}/printers/${printerId}/stream`, { headers }); + return await apiFetch(`/printers/${printerId}/stream`); } catch (err: unknown) { // A 404 means "this printer has no watchable camera", which is expected // often enough that treating it as a failure would put an error state on diff --git a/apps/web/pages/index.vue b/apps/web/pages/index.vue index 3367ccc..4e0a54e 100644 --- a/apps/web/pages/index.vue +++ b/apps/web/pages/index.vue @@ -16,21 +16,18 @@ interface Printer { } const telemetry = useTelemetryStore(); -const config = useRuntimeConfig(); -const { data: printers } = await useFetch(`${config.public.apiBase}/printers`, { - headers: useRequestHeaders(["cookie"]), -}); +const apiFetch = useApiFetch(); + +const { data: printers } = await useAsyncData("printers", () => + apiFetch("/printers"), +); // Looked up per printer, because the answer is per printer, but only for the // printers the list already says have a camera. Asking for all of them would // mean one request per machine in the fleet, most of them 404 by design. const streams = ref>({}); -// Read here, in setup, rather than inside the loop below: Nuxt's request -// helpers are only valid while a component is setting up. -const streamHeaders = useRequestHeaders(["cookie"]) as Record; - watch( printers, (list) => { @@ -41,7 +38,7 @@ watch( // no player, which is also the right thing to show while this is in // flight. streams.value[printer.id] = null; - void fetchPrinterStream(config.public.apiBase, printer.id, streamHeaders).then((stream) => { + void fetchPrinterStream(apiFetch, printer.id).then((stream) => { streams.value[printer.id] = stream; }); }