/** * Local-agent localhost 直連工具 — visionA Cloud 前端(ADR-019 WP-3) * * 職責(純 client util、endpoint 無關): * - `probeLocalAgentPort()`:探測本機 local-agent 的動態 port * (sessionStorage 快取 → 3721 → 3722–3740 並發,各 timeout 500ms,打 GET /api/local/hello)。 * - `resolveLocalAgent(serial)`:探測 + 同機判定 + serial 身分驗證,回傳 { port } 或 * LOCAL_AGENT_NOT_FOUND(無回應)/ LOCAL_AGENT_MISMATCH(有回應但 serial 不符)。 * - `uploadToLocalAgent(port, path, form, options)`:endpoint 無關的通用上傳函式 * (三個 caller 換 path 即可),打 http://127.0.0.1:,帶 X-Visiona-Local-Token。 * * 為什麼是獨立檔(不併進 media.ts): * media.ts 的上傳走雲端 same-origin cookie session(BFF);local-agent 直連走的是 * loopback 絕對 URL + one-time token header,認證模型完全不同(ADR-019 §2.1 混合路徑)。 * 拆開避免兩套認證邏輯混淆。 * * 契約來源(不自行更改,見 api-spec §6.2–6.5 / ADR-019 §2.3): * - port 範圍 3721–3740(local-agent pickPort 動態) * - GET /api/local/hello 回 { serialHashes: string[], supportsLocalUpload: boolean } * - serialHashes[i] = SHA-256("visiona-local-v1" || fullSerial) 的 lowercase hex * - salt 常數 "visiona-local-v1" 前後端共用寫死 * - 上傳路徑 /api/local/media/upload/{video|image|batch-images},Header X-Visiona-Local-Token */ import { AbortError, ApiError, NetworkError, TimeoutError, } from "@/lib/api"; import type { ApiErrorShape } from "@/types/api"; import type { MediaUploadResponse } from "@/types/camera"; import type { UploadMediaOptions } from "@/lib/media"; /* -------------------------------------------------------------------------- */ /* 契約常數 */ /* -------------------------------------------------------------------------- */ /** local-agent loopback host(強制綁 127.0.0.1,見 local-agent server/config.go)。 */ export const LOCAL_AGENT_HOST = "127.0.0.1"; /** port 探測範圍(local-agent pickPort 3721 → 3740 fallback,ADR-019 §2.3)。 */ export const LOCAL_AGENT_PORT_START = 3721; export const LOCAL_AGENT_PORT_END = 3740; /** 每次探測單一 port 的 timeout(毫秒,ADR-019 §2.3)。 */ export const PROBE_TIMEOUT_MS = 500; /** sessionStorage 快取「上次探到的 port」的 key(同分頁 session 內免重掃)。 */ export const PORT_CACHE_KEY = "visiona.localAgent.port"; /** 同機偵測 / 身分驗證用的固定公開 salt(前後端共用寫死,api-spec §6.3;**不可改**)。 */ export const SERIAL_HASH_SALT = "visiona-local-v1"; /** 探測 endpoint 路徑(專用、非 /api/system/health,ADR-019 §2.3)。 */ export const LOCAL_HELLO_PATH = "/api/local/hello"; /** 上傳 token 的 request header 名(api-spec §6.2)。 */ export const LOCAL_TOKEN_HEADER = "X-Visiona-Local-Token"; /** 直連上傳 route 路徑(endpoint 無關函式的 caller 換這三個之一)。 */ export const LOCAL_UPLOAD_IMAGE_PATH = "/api/local/media/upload/image"; export const LOCAL_UPLOAD_VIDEO_PATH = "/api/local/media/upload/video"; export const LOCAL_UPLOAD_BATCH_PATH = "/api/local/media/upload/batch-images"; /* -------------------------------------------------------------------------- */ /* 型別 */ /* -------------------------------------------------------------------------- */ /** GET /api/local/hello 回傳的 data(api-spec §6.3,最小揭露)。 */ export interface LocalHelloData { /** SHA-256("visiona-local-v1" || fullSerial) 的 lowercase hex 陣列。 */ serialHashes: string[]; /** 是否支援 local upload(布林,取代原 agentVersion)。 */ supportsLocalUpload: boolean; } /** 探測到的單一候選(某 port 有回應且回了 hello data)。 */ interface ProbeHit { port: number; data: LocalHelloData; } /** * resolveLocalAgent 的結果碼(對齊 api-spec §6.5 前端內部狀態): * - OK 找到同機且 serial 相符的 agent * - NOT_FOUND 掃描無任何回應(非同機 / agent 沒跑)→ LOCAL_AGENT_NOT_FOUND * - MISMATCH 有回應但沒有任一 serial 相符 → LOCAL_AGENT_MISMATCH */ export type LocalAgentResolveStatus = "OK" | "NOT_FOUND" | "MISMATCH"; export interface LocalAgentResolveResult { status: LocalAgentResolveStatus; /** status === "OK" 時為探到的 port;否則 undefined。 */ port?: number; } /* -------------------------------------------------------------------------- */ /* SHA-256 serial hash(Web Crypto,前端獨立重算比對) */ /* -------------------------------------------------------------------------- */ /** * 算 `SHA-256("visiona-local-v1" || serial)` 的 lowercase hex。 * * 用 Web Crypto `crypto.subtle.digest`(jsdom / Node 20+ / Chrome / Edge 皆有)。 * 與後端 salted SHA-256 契約一致(api-spec §6.3),供同機身分比對。 */ export async function computeSerialHash(serial: string): Promise { const bytes = new TextEncoder().encode(`${SERIAL_HASH_SALT}${serial}`); const digest = await crypto.subtle.digest("SHA-256", bytes); return bytesToHex(new Uint8Array(digest)); } /** Uint8Array → lowercase hex 字串(與後端 hex.EncodeToString 對齊)。 */ function bytesToHex(bytes: Uint8Array): string { let hex = ""; for (const b of bytes) { hex += b.toString(16).padStart(2, "0"); } return hex; } /* -------------------------------------------------------------------------- */ /* sessionStorage 快取 */ /* -------------------------------------------------------------------------- */ /** 讀 sessionStorage 快取的 port(無效 / 不存在 / 超出範圍 → null)。 */ function readCachedPort(): number | null { try { const raw = globalThis.sessionStorage?.getItem(PORT_CACHE_KEY); if (!raw) return null; const port = Number.parseInt(raw, 10); if ( Number.isInteger(port) && port >= LOCAL_AGENT_PORT_START && port <= LOCAL_AGENT_PORT_END ) { return port; } return null; } catch { // sessionStorage 不可用(SSR / 隱私模式)→ 當作無快取 return null; } } /** 寫 sessionStorage 快取(失敗靜默——快取只是最佳化,不可用不影響功能)。 */ function writeCachedPort(port: number): void { try { globalThis.sessionStorage?.setItem(PORT_CACHE_KEY, String(port)); } catch { // ignore:快取寫入失敗不影響探測結果 } } /** 清掉快取(快取的 port 探測失敗時呼叫,避免下次又先撞舊 port)。 */ function clearCachedPort(): void { try { globalThis.sessionStorage?.removeItem(PORT_CACHE_KEY); } catch { // ignore } } /* -------------------------------------------------------------------------- */ /* 單一 port 探測 */ /* -------------------------------------------------------------------------- */ /** * 打單一 port 的 GET /api/local/hello,timeout 500ms。 * * 回傳 ProbeHit(成功且拿到合法 hello data)或 reject(無回應 / 非預期格式 / timeout)。 * 這裡**只判斷「有沒有 local-agent 在這個 port」**,不做 serial 比對(比對在 resolveLocalAgent)。 */ async function probeSinglePort(port: number): Promise { const url = `http://${LOCAL_AGENT_HOST}:${port}${LOCAL_HELLO_PATH}`; const ctrl = new AbortController(); const timeoutId = setTimeout(() => ctrl.abort("probe-timeout"), PROBE_TIMEOUT_MS); try { const res = await fetch(url, { method: "GET", signal: ctrl.signal, // 直連 local-agent 用 header token、不需 cookie(api-spec §6.4 Allow-Credentials: false) credentials: "omit", }); if (!res.ok) { throw new Error(`hello returned HTTP ${res.status}`); } const parsed: unknown = await res.json(); const data = extractHelloData(parsed); if (!data) { throw new Error("hello response shape invalid"); } return { port, data }; } finally { clearTimeout(timeoutId); } } /** 從 hello 回應解 envelope 取 data,並驗證 serialHashes 型別。回傳 null 表格式不符。 */ function extractHelloData(parsed: unknown): LocalHelloData | null { if ( !parsed || typeof parsed !== "object" || !("success" in parsed) || (parsed as { success: unknown }).success !== true || !("data" in parsed) ) { return null; } const data = (parsed as { data: unknown }).data; if (!data || typeof data !== "object" || !("serialHashes" in data)) { return null; } const hashes = (data as { serialHashes: unknown }).serialHashes; if (!Array.isArray(hashes) || !hashes.every((h) => typeof h === "string")) { return null; } const supports = "supportsLocalUpload" in data ? Boolean((data as { supportsLocalUpload: unknown }).supportsLocalUpload) : false; return { serialHashes: hashes as string[], supportsLocalUpload: supports }; } /* -------------------------------------------------------------------------- */ /* port 探測(快取 → 3721 → 3722–3740 並發) */ /* -------------------------------------------------------------------------- */ /** * 探測本機 local-agent 的 port,回傳第一個有回應者的 ProbeHit。 * * 策略(ADR-019 §2.3): * 1. sessionStorage 快取的 port(同分頁 session 內免重掃) * 2. 3721(最常見的預設 port) * 3. 3722–3740 並發(Promise.any 取第一個成功者) * 每次 timeout 500ms。全部失敗 → reject(呼叫端視為 NOT_FOUND)。 * * 為什麼分三段而非一次全並發: * 快取 / 3721 命中率最高,先試可避免每次都對 20 個 port 發 preflight(成本 + 觸發防火牆告警)。 */ export async function probeLocalAgentPort(): Promise { // 1) 快取 const cached = readCachedPort(); if (cached !== null) { try { const hit = await probeSinglePort(cached); writeCachedPort(hit.port); return hit; } catch { // 快取失效(agent 換 port / 沒跑)→ 清掉,往下重掃 clearCachedPort(); } } // 2) 3721 預設 port(避開重複試快取剛失敗的那個) if (cached !== LOCAL_AGENT_PORT_START) { try { const hit = await probeSinglePort(LOCAL_AGENT_PORT_START); writeCachedPort(hit.port); return hit; } catch { // 往下並發掃剩餘範圍 } } // 3) 3722–3740 並發,取第一個成功者 const rest: number[] = []; for (let p = LOCAL_AGENT_PORT_START + 1; p <= LOCAL_AGENT_PORT_END; p++) { if (p !== cached) rest.push(p); } if (rest.length === 0) { throw new NetworkError("No local-agent found on any candidate port"); } try { const hit = await Promise.any(rest.map((p) => probeSinglePort(p))); writeCachedPort(hit.port); return hit; } catch { // Promise.any 全 reject → AggregateError throw new NetworkError("No local-agent found on any candidate port"); } } /* -------------------------------------------------------------------------- */ /* 同機判定 + serial 身分驗證 */ /* -------------------------------------------------------------------------- */ /** * 探測 + 同機判定 + serial 身分驗證。 * * @param serial 目前選定裝置的 serialNumber(kn_number;ADR-018 serial 路由) * @returns * - { status: "OK", port } 找到同機 agent 且其 serialHashes 含此 serial 的雜湊 * - { status: "NOT_FOUND" } 掃描無回應(非同機 / agent 沒跑)→ LOCAL_AGENT_NOT_FOUND * - { status: "MISMATCH" } 有回應但無任一 serial 相符(同機跑著別台 agent)→ LOCAL_AGENT_MISMATCH * * 為什麼比對 serial:防「同機跑著另一台 agent、影片被送到錯的裝置」的靜默錯誤(ADR-019 §2.3 / R-4)。 */ export async function resolveLocalAgent( serial: string, ): Promise { let hit: ProbeHit; try { hit = await probeLocalAgentPort(); } catch { return { status: "NOT_FOUND" }; } const expected = await computeSerialHash(serial); if (hit.data.serialHashes.includes(expected)) { return { status: "OK", port: hit.port }; } // 有回應但 serial 不符 → 快取的 port 可能是別台 agent,清掉避免誤導下次 clearCachedPort(); return { status: "MISMATCH" }; } /* -------------------------------------------------------------------------- */ /* 通用上傳(endpoint 無關,帶 token header) */ /* -------------------------------------------------------------------------- */ /** uploadToLocalAgent 的選項:沿用 media 的進度 / 取消 / timeout,加 token。 */ export interface LocalUploadOptions extends UploadMediaOptions { /** one-time upload token(經雲端 ticket 取得,放 X-Visiona-Local-Token header)。 */ token: string; } /** * 以 XHR + FormData 直連 local-agent 上傳 multipart(endpoint 無關)。 * * 三個 caller(image / video / batch)只需換 `path`: * LOCAL_UPLOAD_IMAGE_PATH | LOCAL_UPLOAD_VIDEO_PATH | LOCAL_UPLOAD_BATCH_PATH * * @param port probeLocalAgentPort / resolveLocalAgent 探到的 port * @param path 直連上傳 route 相對路徑(/api/local/media/upload/*) * @param form 已組好的 FormData(含 deviceId + file/files,格式與雲端 route 相同) * @param options token + 進度 / 取消 / timeout * @returns 解開 envelope 的 MediaUploadResponse(與雲端 route 回傳格式相同) * @throws ApiError | NetworkError | TimeoutError | AbortError(與 api.ts 一致的錯誤體系) * * 認證與雲端 route 不同: * - 走絕對 loopback URL(http://127.0.0.1:),非 same-origin * - credentials 不帶 cookie(withCredentials = false;api-spec §6.4 Allow-Credentials: false) * - token 放 header X-Visiona-Local-Token(不放 URL,避免洩漏到 log / referrer) */ export function uploadToLocalAgent( port: number, path: string, form: FormData, options: LocalUploadOptions, ): Promise { const normalizedPath = path.startsWith("/") ? path : `/${path}`; const url = `http://${LOCAL_AGENT_HOST}:${port}${normalizedPath}`; return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("POST", url, true); // 直連 loopback:不帶 cookie(token 走 header) xhr.withCredentials = false; // 不設 Content-Type,讓瀏覽器帶 multipart boundary xhr.setRequestHeader(LOCAL_TOKEN_HEADER, options.token); if (options.timeoutMs && options.timeoutMs > 0) { xhr.timeout = options.timeoutMs; } if (options.onProgress) { xhr.upload.onprogress = (ev) => { if (ev.lengthComputable) { options.onProgress!( Math.min(100, Math.round((ev.loaded / ev.total) * 100)), ); } }; } xhr.onload = () => { let parsed: unknown = null; try { parsed = xhr.responseText ? JSON.parse(xhr.responseText) : null; } catch { // 非 JSON body } if (xhr.status >= 200 && xhr.status < 300) { if ( parsed && typeof parsed === "object" && "success" in parsed && (parsed as { success: boolean }).success === true && "data" in parsed ) { resolve((parsed as { data: MediaUploadResponse }).data); return; } reject( new ApiError(xhr.status, { code: "PARSE_ERROR", message: "Unexpected upload response shape", }), ); return; } // non-2xx:盡量取 envelope 的 error(LOCAL_TOKEN_INVALID / LOCAL_UPLOAD_TOO_LARGE 等) let errShape: ApiErrorShape = { code: xhr.status === 401 ? "LOCAL_TOKEN_INVALID" : "INTERNAL_ERROR", message: `Local upload failed: HTTP ${xhr.status}`, }; if ( parsed && typeof parsed === "object" && "error" in parsed && (parsed as { error?: unknown }).error ) { errShape = (parsed as { error: ApiErrorShape }).error; } reject(new ApiError(xhr.status, errShape)); }; xhr.onerror = () => reject(new NetworkError(`Local upload to ${url} failed`)); xhr.ontimeout = () => reject(new TimeoutError(`Local upload to ${url} timed out`)); if (options.signal) { if (options.signal.aborted) { reject(new AbortError()); return; } options.signal.addEventListener( "abort", () => { xhr.abort(); reject(new AbortError()); }, { once: true }, ); } xhr.send(form); }); }