/** * Local-agent 直連上傳的「編排層」— visionA Cloud 前端(ADR-019 WP-4) * * 職責(把 WP-3 的低階 util 串成影片分頁要用的完整流程): * 1. `getLocalUploadTicket(serial)`:向雲端 `POST /api/devices/:serial/local-upload-ticket` * 取 one-time upload token(走既有 OIDC cookie session + tunnel,見 api-spec §6.3)。 * 2. `uploadVideoViaLocalAgent(serial, file, opts)`:取 token → 探測同機 local-agent * → 直連 POST `/api/local/media/upload/video`(帶 X-Visiona-Local-Token)。 * * 與 media.ts(雲端 tunnel 路徑)的分工: * - media.ts 的 uploadVideo 走雲端 same-origin cookie(BFF)+ tunnel forward(保留不動)。 * - 本檔走「上傳走 localhost、控制面(token)走雲端」的混合路徑(ADR-019 §2.1)。 * - 影片分頁 **完全切 localhost、不做 tunnel fallback**(ADR §4.2「非同機停用分頁」), * 故本檔不引用 media.ts 的 uploadVideo。 * * 錯誤模型(讓 caller 能對 UI 分流 i18n,api-spec §6.5): * - resolve 階段用 `LocalMediaError`(code: LOCAL_AGENT_NOT_FOUND / LOCAL_AGENT_MISMATCH) * - 取 token / 上傳階段的 HTTP 錯誤沿用 api.ts 的 ApiError(code: LOCAL_TOKEN_INVALID / * LOCAL_TOKEN_LIMIT / LOCAL_UPLOAD_TOO_LARGE / TUNNEL_DISCONNECTED / …)。 * caller(workspace-client 影片分頁)以 error.code 對應具體 i18n 文案。 */ import { api } from "@/lib/api"; import { LOCAL_UPLOAD_VIDEO_PATH, resolveLocalAgent, uploadToLocalAgent, } from "@/lib/local-agent"; import type { UploadMediaOptions } from "@/lib/media"; import type { KnownErrorCode } from "@/types/api"; import type { MediaUploadResponse } from "@/types/camera"; /** 雲端 ticket endpoint(api-spec §6.3;驗 OIDC session + 裝置歸屬後經 tunnel issue-token)。 */ export const LOCAL_UPLOAD_TICKET_PATH = (serial: string): string => `/api/devices/${encodeURIComponent(serial)}/local-upload-ticket`; /** ticket endpoint 回傳(api-spec §6.3:issue-token 的 data 透傳)。 */ export interface LocalUploadTicket { token: string; /** token 到期時間(unix ms)。 */ expiresAt?: number; /** TTL 秒數(契約固定 120s)。 */ ttlSeconds?: number; } /** * resolve local-agent 階段的錯誤(純前端狀態、非後端回傳的 HTTP 錯誤)。 * * 為什麼獨立一個 Error 類別而非沿用 ApiError: * NOT_FOUND / MISMATCH 是「前端掃描結論」(api-spec §6.5 標「—(前端內部狀態)」), * 沒有 HTTP status;用專屬類別讓 caller 能 `instanceof` 精準分流,且 code 型別安全。 */ export class LocalMediaError extends Error { readonly code: Extract< KnownErrorCode, "LOCAL_AGENT_NOT_FOUND" | "LOCAL_AGENT_MISMATCH" >; constructor( code: LocalMediaError["code"], message = code, ) { super(message); this.name = "LocalMediaError"; this.code = code; } } /** * 向雲端要 one-time upload token(走既有 OIDC cookie session + tunnel)。 * * @param serial 目前裝置 serialNumber(kn_number;ADR-018 serial 路由) * @throws ApiError(TUNNEL_DISCONNECTED / LOCAL_TOKEN_LIMIT / … 由後端 envelope 帶出) */ export function getLocalUploadTicket( serial: string, ): Promise { // api.post 已解 envelope、回 data;走 same-origin cookie(BFF),與其他雲端呼叫一致。 return api.post(LOCAL_UPLOAD_TICKET_PATH(serial)); } /** uploadVideoViaLocalAgent 的選項(沿用 media 的進度 / 取消 / timeout)。 */ export type UploadLocalVideoOptions = UploadMediaOptions; /** * 影片分頁的完整直連上傳流程(ADR-019 §2.4 契約 [1]→[4])。 * * 流程: * 1. 取 token(雲端 ticket)— 失敗直接往上拋(ApiError)。 * 2. resolveLocalAgent(serial) → 探測 + 同機 + serial 身分驗證。 * NOT_FOUND / MISMATCH → 拋 LocalMediaError(caller 對應 i18n)。 * 3. uploadToLocalAgent(port, /api/local/media/upload/video, form, { token })。 * * **前提(caller 責任)**:呼叫前須已確認裝置 tunnel 在線(R-3)。tunnel 離線時 * token 取不到(雲端經 tunnel issue-token 會失敗),但為了給使用者清楚提示, * caller(影片分頁)應在 UI 層先 disable 上傳、不進到這裡(見 workspace-client)。 * * @param serial 裝置 serialNumber(kn_number) * @param file 影片檔(副檔名 / 大小驗證由 caller 先做,見 media.ts validateLocalVideoFile) * @param options 進度 / 取消 / timeout * @returns 解開 envelope 的 MediaUploadResponse(含 streamUrl / totalFrames / durationSeconds) * @throws LocalMediaError | ApiError | NetworkError | TimeoutError | AbortError */ export async function uploadVideoViaLocalAgent( serial: string, file: File, options: UploadLocalVideoOptions = {}, ): Promise { // [1] 取 token(雲端;此步失敗會拋 ApiError,含 TUNNEL_DISCONNECTED 等) const ticket = await getLocalUploadTicket(serial); // 已取消就不必再探測 / 上傳 if (options.signal?.aborted) { // 讓 caller 的 abort 分支一致:沿用 local-agent 的 AbortError(透過 upload 拋) // 這裡直接進入 upload 前的 guard 由 uploadToLocalAgent 處理 aborted signal。 } // [2] 探測同機 local-agent + serial 身分驗證 const resolved = await resolveLocalAgent(serial); if (resolved.status === "NOT_FOUND") { throw new LocalMediaError("LOCAL_AGENT_NOT_FOUND"); } if (resolved.status === "MISMATCH" || resolved.port === undefined) { throw new LocalMediaError("LOCAL_AGENT_MISMATCH"); } // [3] 直連上傳(FormData 欄位名維持 deviceId、值帶 serial,與雲端 route 相同) const form = new FormData(); form.append("deviceId", serial); form.append("file", file); return uploadToLocalAgent(resolved.port, LOCAL_UPLOAD_VIDEO_PATH, form, { ...options, token: ticket.token, }); }