128 lines
4.0 KiB
TypeScript
128 lines
4.0 KiB
TypeScript
import {
|
|
FIELDLENS_SCHEMA_VERSION,
|
|
type DeviceRegistration,
|
|
type Station,
|
|
type SyncResponse,
|
|
} from '@fieldlens/contracts'
|
|
import {
|
|
FIELDLENS_SYNC_REQUEST_EVENT,
|
|
acknowledgeOutboxEvent,
|
|
failOutboxEvent,
|
|
getInstallationId,
|
|
getLocalMetadata,
|
|
getPendingOutbox,
|
|
setLocalMetadata,
|
|
} from '../../../packages/fieldlens-core/src/lib/database'
|
|
import { getAccessToken } from './auth'
|
|
|
|
const apiBase = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '')
|
|
|
|
export type RegisteredDevice = {
|
|
id: string
|
|
station: Station
|
|
}
|
|
|
|
async function authenticatedFetch(path: string, init: RequestInit = {}) {
|
|
const token = await getAccessToken()
|
|
if (!token) throw new Error('Authentication unavailable')
|
|
const headers = new Headers(init.headers)
|
|
headers.set('Authorization', `Bearer ${token}`)
|
|
if (init.body) headers.set('Content-Type', 'application/json')
|
|
const response = await fetch(`${apiBase}${path}`, { ...init, headers })
|
|
if (!response.ok) throw new Error(`API ${response.status}`)
|
|
return response
|
|
}
|
|
|
|
export async function ensureDeviceRegistered(): Promise<RegisteredDevice | null> {
|
|
const existing = await getLocalMetadata<RegisteredDevice>('registeredDevice')
|
|
const token = await getAccessToken()
|
|
if (!token) return existing ?? null
|
|
|
|
const installationId = await getInstallationId()
|
|
const registration: DeviceRegistration = {
|
|
installationId,
|
|
appVersion: import.meta.env.VITE_APP_VERSION ?? 'dev',
|
|
schemaVersion: FIELDLENS_SCHEMA_VERSION,
|
|
}
|
|
try {
|
|
const response = await authenticatedFetch('/api/v1/devices/register', {
|
|
method: 'POST',
|
|
body: JSON.stringify(registration),
|
|
})
|
|
const next = await response.json() as RegisteredDevice
|
|
await setLocalMetadata('registeredDevice', next)
|
|
return next
|
|
} catch {
|
|
return existing ?? null
|
|
}
|
|
}
|
|
|
|
let syncInFlight: Promise<void> | null = null
|
|
let syncRequested = false
|
|
|
|
export function syncNow() {
|
|
syncRequested = true
|
|
if (syncInFlight) return syncInFlight
|
|
syncInFlight = (async () => {
|
|
do {
|
|
syncRequested = false
|
|
await runSync()
|
|
} while (syncRequested)
|
|
})().finally(() => { syncInFlight = null })
|
|
return syncInFlight
|
|
}
|
|
|
|
async function runSync() {
|
|
const device = await ensureDeviceRegistered()
|
|
if (!device) return
|
|
|
|
const events = await getPendingOutbox(50)
|
|
if (events.length === 0) return
|
|
|
|
try {
|
|
const response = await authenticatedFetch('/api/v1/sync', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
deviceId: device.id,
|
|
schemaVersion: FIELDLENS_SCHEMA_VERSION,
|
|
events: events.map(({ attempts: _attempts, nextAttemptAt: _next, syncedAt: _synced, lastError: _error, ...event }) => event),
|
|
}),
|
|
})
|
|
const body = await response.json() as SyncResponse
|
|
const acknowledgements = new Map(body.acknowledgements.map((ack) => [ack.eventId, ack]))
|
|
|
|
for (const event of events) {
|
|
const acknowledgement = acknowledgements.get(event.id)
|
|
if (acknowledgement?.status === 'accepted' || acknowledgement?.status === 'duplicate') {
|
|
await acknowledgeOutboxEvent(event.id)
|
|
} else {
|
|
await failOutboxEvent(event.id, acknowledgement?.error ?? 'Event was not acknowledged')
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Sync request failed'
|
|
await Promise.all(events.map((event) => failOutboxEvent(event.id, message)))
|
|
}
|
|
}
|
|
|
|
export function installSyncTriggers() {
|
|
const attempt = () => { void syncNow() }
|
|
const onVisible = () => {
|
|
if (document.visibilityState === 'visible') attempt()
|
|
}
|
|
window.addEventListener('online', attempt)
|
|
window.addEventListener(FIELDLENS_SYNC_REQUEST_EVENT, attempt)
|
|
document.addEventListener('visibilitychange', onVisible)
|
|
const interval = window.setInterval(attempt, 30_000)
|
|
attempt()
|
|
|
|
return () => {
|
|
window.removeEventListener('online', attempt)
|
|
window.removeEventListener(FIELDLENS_SYNC_REQUEST_EVENT, attempt)
|
|
document.removeEventListener('visibilitychange', onVisible)
|
|
window.clearInterval(interval)
|
|
}
|
|
}
|
|
|
|
export { authenticatedFetch }
|