refactor: remove WebSocket implementation and switch to HTTP-only sync with configurable polling interval
This commit is contained in:
-166
@@ -1,166 +0,0 @@
|
||||
package com.collabtable.app.data.api
|
||||
|
||||
import android.content.Context
|
||||
import com.collabtable.app.data.preferences.PreferencesManager
|
||||
import com.collabtable.app.utils.Logger
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonObject
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private data class MessageEnvelope(
|
||||
val id: String? = null,
|
||||
val type: String,
|
||||
val payload: Any? = null,
|
||||
)
|
||||
|
||||
object WebSocketSyncClient {
|
||||
private val gson = Gson()
|
||||
|
||||
private val logging =
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BASIC
|
||||
}
|
||||
|
||||
private val client: OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(logging)
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.pingInterval(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private fun buildWebSocketUrl(httpApiBaseUrl: String): String {
|
||||
// Expecting something like: http://host:port/api/
|
||||
var url = httpApiBaseUrl
|
||||
// Ensure trailing slash removed for manipulation
|
||||
if (url.endsWith("/")) url = url.dropLast(1)
|
||||
|
||||
// Replace scheme
|
||||
url =
|
||||
when {
|
||||
url.startsWith("https://") -> "wss://" + url.removePrefix("https://")
|
||||
url.startsWith("http://") -> "ws://" + url.removePrefix("http://")
|
||||
else -> "ws://$url" // best effort
|
||||
}
|
||||
|
||||
// Replace trailing /api with /api/ws
|
||||
return if (url.endsWith("/api")) "$url/ws" else "$url/api/ws"
|
||||
}
|
||||
|
||||
suspend fun sync(
|
||||
context: Context,
|
||||
request: SyncRequest,
|
||||
): Result<SyncResponse> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val prefs = PreferencesManager.getInstance(context)
|
||||
val baseUrl = prefs.getServerUrl()
|
||||
val password = prefs.getServerPassword()
|
||||
val wsUrl = buildWebSocketUrl(baseUrl)
|
||||
val messageId = UUID.randomUUID().toString()
|
||||
|
||||
val deferred = CompletableDeferred<Result<SyncResponse>>()
|
||||
var socket: WebSocket? = null
|
||||
|
||||
val httpRequestBuilder = Request.Builder().url(wsUrl)
|
||||
if (!password.isNullOrBlank()) {
|
||||
httpRequestBuilder.addHeader("Authorization", "Bearer $password")
|
||||
}
|
||||
val httpRequest = httpRequestBuilder.build()
|
||||
|
||||
val listener =
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) {
|
||||
// Send sync message when opened
|
||||
val envelope =
|
||||
MessageEnvelope(
|
||||
id = messageId,
|
||||
type = "sync",
|
||||
payload = request,
|
||||
)
|
||||
val json = gson.toJson(envelope)
|
||||
webSocket.send(json)
|
||||
}
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
try {
|
||||
val root = gson.fromJson(text, JsonObject::class.java)
|
||||
val type = root.get("type")?.asString
|
||||
val id = root.get("id")?.asString
|
||||
if (type == "syncResponse" && id == messageId) {
|
||||
val payload = root.getAsJsonObject("payload")
|
||||
val resp = gson.fromJson(payload, SyncResponse::class.java)
|
||||
if (!deferred.isCompleted) {
|
||||
deferred.complete(Result.success(resp))
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
} else if (type == "error" && !deferred.isCompleted) {
|
||||
val msg = root.getAsJsonObject("payload")?.get("message")?.asString ?: "WebSocket error"
|
||||
deferred.complete(Result.failure(Exception(msg)))
|
||||
webSocket.close(1002, "error")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (!deferred.isCompleted) {
|
||||
deferred.complete(Result.failure(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosing(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
// 1008 indicates policy violation, used here for Unauthorized
|
||||
if (!deferred.isCompleted) {
|
||||
if (code == 1008) {
|
||||
deferred.complete(Result.failure(Exception("Unauthorized (WS 1008)")))
|
||||
} else {
|
||||
deferred.complete(Result.failure(Exception("WebSocket closing ($code): $reason")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
webSocket: WebSocket,
|
||||
t: Throwable,
|
||||
response: Response?,
|
||||
) {
|
||||
if (!deferred.isCompleted) {
|
||||
deferred.complete(Result.failure(t))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
socket = client.newWebSocket(httpRequest, listener)
|
||||
// Wait up to 30s for response (WS roundtrip)
|
||||
val result = withTimeout(30_000) { deferred.await() }
|
||||
return@withContext result
|
||||
} catch (e: Exception) {
|
||||
Logger.w("WS", "Falling back to HTTP sync: ${e.message}")
|
||||
return@withContext Result.failure(e)
|
||||
} finally {
|
||||
// OkHttp cleans up sockets automatically when no references remain
|
||||
// but we try to close if still open
|
||||
socket?.close(1000, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -25,6 +25,10 @@ class PreferencesManager(context: Context) {
|
||||
private val _sortOrder = MutableStateFlow(getSortOrder())
|
||||
val sortOrder: StateFlow<String> = _sortOrder.asStateFlow()
|
||||
|
||||
// Sync polling interval in milliseconds (configurable)
|
||||
private val _syncPollIntervalMs = MutableStateFlow(getSyncPollIntervalMs())
|
||||
val syncPollIntervalMs: StateFlow<Long> = _syncPollIntervalMs.asStateFlow()
|
||||
|
||||
fun getServerUrl(): String {
|
||||
return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL
|
||||
}
|
||||
@@ -124,6 +128,18 @@ class PreferencesManager(context: Context) {
|
||||
_sortOrder.value = normalized
|
||||
}
|
||||
|
||||
// Sync interval: read/write with sensible bounds to avoid too-aggressive polling
|
||||
fun getSyncPollIntervalMs(): Long {
|
||||
val raw = prefs.getLong(KEY_SYNC_POLL_INTERVAL_MS, DEFAULT_SYNC_POLL_INTERVAL_MS)
|
||||
return raw.coerceIn(MIN_SYNC_POLL_INTERVAL_MS, MAX_SYNC_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
fun setSyncPollIntervalMs(ms: Long) {
|
||||
val clamped = ms.coerceIn(MIN_SYNC_POLL_INTERVAL_MS, MAX_SYNC_POLL_INTERVAL_MS)
|
||||
prefs.edit().putLong(KEY_SYNC_POLL_INTERVAL_MS, clamped).apply()
|
||||
_syncPollIntervalMs.value = clamped
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_SERVER_URL = "server_url"
|
||||
private const val KEY_FIRST_RUN = "first_run"
|
||||
@@ -133,7 +149,11 @@ class PreferencesManager(context: Context) {
|
||||
private const val KEY_DYNAMIC_COLOR = "dynamic_color"
|
||||
private const val KEY_AMOLED_DARK = "amoled_dark"
|
||||
private const val KEY_SORT_ORDER = "sort_order"
|
||||
private const val KEY_SYNC_POLL_INTERVAL_MS = "sync_poll_interval_ms"
|
||||
private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/"
|
||||
private const val DEFAULT_SYNC_POLL_INTERVAL_MS = 5000L
|
||||
private const val MIN_SYNC_POLL_INTERVAL_MS = 250L
|
||||
private const val MAX_SYNC_POLL_INTERVAL_MS = 600_000L // 10 minutes
|
||||
const val THEME_MODE_SYSTEM = "system"
|
||||
const val THEME_MODE_LIGHT = "light"
|
||||
const val THEME_MODE_DARK = "dark"
|
||||
|
||||
+2
-9
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import androidx.room.withTransaction
|
||||
import com.collabtable.app.data.api.ApiClient
|
||||
import com.collabtable.app.data.api.SyncRequest
|
||||
import com.collabtable.app.data.api.WebSocketSyncClient
|
||||
import com.collabtable.app.data.database.CollabTableDatabase
|
||||
import com.collabtable.app.utils.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -60,12 +59,7 @@ class SyncRepository(context: Context) {
|
||||
itemValues = localValues,
|
||||
)
|
||||
|
||||
// Try WebSocket first; on failure, fall back to HTTP
|
||||
val wsResult = WebSocketSyncClient.sync(appContext, syncRequest)
|
||||
val syncResponse =
|
||||
if (wsResult.isSuccess) {
|
||||
wsResult.getOrThrow()
|
||||
} else {
|
||||
// HTTP-only sync
|
||||
val response = api.sync(syncRequest)
|
||||
if (!response.isSuccessful) {
|
||||
if (response.code() == 401) {
|
||||
@@ -77,8 +71,7 @@ class SyncRepository(context: Context) {
|
||||
Logger.e("Sync", "[ERROR] Sync failed: HTTP ${response.code()}")
|
||||
return@withContext Result.failure(Exception("Sync failed: ${response.code()}"))
|
||||
}
|
||||
response.body()!!
|
||||
}
|
||||
val syncResponse = response.body()!!
|
||||
// Only log receive when there are actual server changes
|
||||
val inTotal =
|
||||
syncResponse.lists.size +
|
||||
|
||||
+4
-18
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
@@ -2522,11 +2523,8 @@ fun ManageColumnsDialog(
|
||||
val reorderState =
|
||||
rememberReorderableLazyListState(
|
||||
onMove = { from, to ->
|
||||
// Map from Lazy positions (with dividers) to data indices.
|
||||
// Insert AFTER the upper item when hovering between rows.
|
||||
val fromData = from.index / 2
|
||||
val toData = (to.index + 1) / 2
|
||||
reorderedFields.move(fromData, toData)
|
||||
// Indices from reorderable refer to draggable items only
|
||||
reorderedFields.move(from.index, to.index)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2540,18 +2538,7 @@ fun ManageColumnsDialog(
|
||||
contentPadding = PaddingValues(vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
// Interleave dividers as separate Lazy items so dividers don't move with dragged rows
|
||||
items(
|
||||
count = if (reorderedFields.isEmpty()) 0 else reorderedFields.size * 2 - 1,
|
||||
key = { pos ->
|
||||
if (pos % 2 == 0) reorderedFields[pos / 2].id else "divider-$pos"
|
||||
},
|
||||
) { pos ->
|
||||
if (pos % 2 == 1) {
|
||||
Divider()
|
||||
} else {
|
||||
val index = pos / 2
|
||||
val field = reorderedFields[index]
|
||||
itemsIndexed(reorderedFields, key = { _, field -> field.id }) { index, field ->
|
||||
ReorderableItem(
|
||||
reorderState,
|
||||
key = field.id,
|
||||
@@ -2590,7 +2577,6 @@ fun ManageColumnsDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
|
||||
+3
-1
@@ -65,9 +65,11 @@ class ListDetailViewModel(
|
||||
|
||||
private fun startPeriodicSync() {
|
||||
viewModelScope.launch {
|
||||
val prefs = com.collabtable.app.data.preferences.PreferencesManager.getInstance(context)
|
||||
while (true) {
|
||||
performSync()
|
||||
kotlinx.coroutines.delay(5000) // Sync every 5 seconds
|
||||
val delayMs = prefs.getSyncPollIntervalMs()
|
||||
kotlinx.coroutines.delay(delayMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
@@ -27,7 +27,6 @@ import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Sort
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -173,11 +172,8 @@ fun ListsScreen(
|
||||
onMove = { from, to ->
|
||||
dragging = true
|
||||
val newList = working.toMutableList()
|
||||
// Map from Lazy positions (with dividers) to data indices.
|
||||
// to.index can be odd (between items). Insert AFTER the upper item for better feel.
|
||||
val fromData = from.index / 2
|
||||
val toData = (to.index + 1) / 2
|
||||
newList.move(fromData, toData)
|
||||
// Indices provided by reorderable correspond to draggable items only (dividers ignored)
|
||||
newList.move(from.index, to.index)
|
||||
working = newList
|
||||
},
|
||||
onDragEnd = { _, _ ->
|
||||
@@ -195,18 +191,7 @@ fun ListsScreen(
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
// Interleave dividers as separate list items so they don't move with dragged rows
|
||||
items(
|
||||
count = if (working.isEmpty()) 0 else working.size * 2 - 1,
|
||||
key = { pos ->
|
||||
if (pos % 2 == 0) working[pos / 2].id else "divider-$pos"
|
||||
},
|
||||
) { pos ->
|
||||
if (pos % 2 == 1) {
|
||||
Divider()
|
||||
} else {
|
||||
val index = pos / 2
|
||||
val list = working[index]
|
||||
itemsIndexed(working, key = { _, it -> it.id }) { _, list ->
|
||||
ReorderableItem(reorderState, key = list.id) { _ ->
|
||||
Box(modifier = Modifier.animateItemPlacement()) {
|
||||
ListItem(
|
||||
@@ -230,7 +215,6 @@ fun ListsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreateDialog) {
|
||||
CreateListDialog(
|
||||
|
||||
+3
-1
@@ -99,8 +99,10 @@ class ListsViewModel(
|
||||
}
|
||||
|
||||
private suspend fun startPeriodicSync() {
|
||||
val prefs = com.collabtable.app.data.preferences.PreferencesManager.getInstance(context)
|
||||
while (true) {
|
||||
kotlinx.coroutines.delay(5000) // Wait 5 seconds before next sync
|
||||
val delayMs = prefs.getSyncPollIntervalMs()
|
||||
kotlinx.coroutines.delay(delayMs)
|
||||
performSync()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -72,6 +73,9 @@ fun SettingsScreen(
|
||||
val displayUrl = remember(serverUrl) { formatServerUrlForDisplay(serverUrl) }
|
||||
var showLeaveDialog by remember { mutableStateOf(false) }
|
||||
var isLeaving by remember { mutableStateOf(false) }
|
||||
val syncIntervalMs by preferencesManager.syncPollIntervalMs.collectAsState()
|
||||
var syncIntervalInput by remember(syncIntervalMs) { mutableStateOf(syncIntervalMs.toString()) }
|
||||
var syncIntervalError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -216,6 +220,72 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Sync settings
|
||||
Text(
|
||||
text = "Sync",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "HTTP polling interval (ms)",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextField(
|
||||
value = syncIntervalInput,
|
||||
onValueChange = {
|
||||
syncIntervalInput = it.filter { ch -> ch.isDigit() }
|
||||
syncIntervalError = null
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
placeholder = { Text("5000") },
|
||||
)
|
||||
Button(onClick = {
|
||||
val parsed = syncIntervalInput.toLongOrNull()
|
||||
if (parsed == null) {
|
||||
syncIntervalError = "Enter a valid number"
|
||||
} else {
|
||||
// Bounds are enforced in setter
|
||||
preferencesManager.setSyncPollIntervalMs(parsed)
|
||||
syncIntervalError = null
|
||||
}
|
||||
}) {
|
||||
Text("Apply")
|
||||
}
|
||||
}
|
||||
if (syncIntervalError != null) {
|
||||
Text(
|
||||
text = syncIntervalError!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "Current: ${'$'}syncIntervalMs ms (min 250, max 600000)",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"cors": "^2.8.5",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"ws": "^8.16.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
||||
"@typescript-eslint/parser": "^7.0.2",
|
||||
"eslint": "^8.57.0",
|
||||
@@ -32,7 +31,6 @@
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/pg": "^8.10.2",
|
||||
"typescript": "^5.3.3",
|
||||
"ts-node-dev": "^2.0.0"
|
||||
|
||||
@@ -8,8 +8,6 @@ import fieldRoutes from './routes/fieldRoutes';
|
||||
import itemRoutes from './routes/itemRoutes';
|
||||
import syncRoutes from './routes/syncRoutes';
|
||||
import webRoutes from './routes/webRoutes';
|
||||
import { createServer } from 'http';
|
||||
import { initWebSocket } from './ws';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -48,9 +46,8 @@ app.use('/', webRoutes);
|
||||
console.log('- Items');
|
||||
console.log('- ItemValues');
|
||||
|
||||
const server = createServer(app);
|
||||
initWebSocket(server);
|
||||
server.listen(PORT, () => {
|
||||
// Start HTTP server (WebSocket removed)
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
const dbClient = (process.env.DB_CLIENT || process.env.DB_TYPE || 'sqlite').toLowerCase();
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { Server as HttpServer, IncomingMessage } from 'http';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { dbAdapter } from './db';
|
||||
|
||||
interface SyncRequest {
|
||||
lastSyncTimestamp: number;
|
||||
lists: any[];
|
||||
fields: any[];
|
||||
items: any[];
|
||||
itemValues: any[];
|
||||
}
|
||||
|
||||
interface MessageEnvelope<T = any> {
|
||||
id?: string;
|
||||
type: string;
|
||||
payload?: T;
|
||||
}
|
||||
|
||||
function checkAuth(req: any): boolean {
|
||||
const serverPassword = process.env.SERVER_PASSWORD;
|
||||
if (!serverPassword) return true; // auth disabled
|
||||
const auth = req.headers?.authorization as string | undefined;
|
||||
if (!auth) return false;
|
||||
const parts = auth.split(' ');
|
||||
return parts.length === 2 && parts[0] === 'Bearer' && parts[1] === serverPassword;
|
||||
}
|
||||
|
||||
const UPSERT_LIST = 'INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted';
|
||||
const UPSERT_FIELD = 'INSERT INTO fields (id, name, fieldType, fieldOptions, listId, "order", createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, fieldType = EXCLUDED.fieldType, fieldOptions = EXCLUDED.fieldOptions, "order" = EXCLUDED."order", updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted';
|
||||
const UPSERT_ITEM = 'INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted';
|
||||
const UPSERT_ITEM_VALUE = 'INSERT INTO item_values (id, itemId, fieldId, value, updatedAt) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET value = EXCLUDED.value, updatedAt = EXCLUDED.updatedAt';
|
||||
|
||||
async function processSync(data: SyncRequest) {
|
||||
await dbAdapter.transaction(async (tx) => {
|
||||
for (const list of data.lists || []) {
|
||||
await tx.execute(UPSERT_LIST, [list.id, list.name, list.createdAt, list.updatedAt, list.isDeleted ? 1 : 0]);
|
||||
}
|
||||
for (const field of data.fields || []) {
|
||||
await tx.execute(UPSERT_FIELD, [field.id, field.name, field.fieldType, field.fieldOptions, field.listId, field.order, field.createdAt, field.updatedAt, field.isDeleted ? 1 : 0]);
|
||||
}
|
||||
for (const item of data.items || []) {
|
||||
await tx.execute(UPSERT_ITEM, [item.id, item.listId, item.createdAt, item.updatedAt, item.isDeleted ? 1 : 0]);
|
||||
}
|
||||
for (const val of data.itemValues || []) {
|
||||
await tx.execute(UPSERT_ITEM_VALUE, [val.id, val.itemId, val.fieldId, val.value, val.updatedAt]);
|
||||
}
|
||||
});
|
||||
|
||||
let serverLists: any[], serverFields: any[], serverItems: any[], serverItemValues: any[];
|
||||
const since = data.lastSyncTimestamp;
|
||||
if (since === 0) {
|
||||
serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE isDeleted = 0');
|
||||
serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE isDeleted = 0');
|
||||
serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE isDeleted = 0');
|
||||
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values');
|
||||
} else {
|
||||
serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE updatedAt >= ?', [since]);
|
||||
serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE updatedAt >= ?', [since]);
|
||||
serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE updatedAt >= ?', [since]);
|
||||
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values WHERE updatedAt >= ?', [since]);
|
||||
}
|
||||
|
||||
serverLists = serverLists.map(l => ({ ...l, isDeleted: !!l.isDeleted }));
|
||||
serverFields = serverFields.map(f => ({ ...f, isDeleted: !!f.isDeleted }));
|
||||
serverItems = serverItems.map(i => ({ ...i, isDeleted: !!i.isDeleted }));
|
||||
|
||||
return {
|
||||
lists: serverLists,
|
||||
fields: serverFields,
|
||||
items: serverItems,
|
||||
itemValues: serverItemValues,
|
||||
serverTimestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function initWebSocket(server: HttpServer) {
|
||||
const wss = new WebSocketServer({ server, path: '/api/ws' });
|
||||
console.log('WebSocket server initialized at /api/ws');
|
||||
|
||||
wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
|
||||
if (!checkAuth(req)) {
|
||||
ws.close(1008, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
ws.on('message', async (raw: Buffer) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString()) as MessageEnvelope;
|
||||
if (msg.type === 'ping') {
|
||||
const resp: MessageEnvelope = { type: 'pong', id: msg.id };
|
||||
ws.send(JSON.stringify(resp));
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'sync') {
|
||||
const respData = await processSync(msg.payload as SyncRequest);
|
||||
const resp: MessageEnvelope = { type: 'syncResponse', id: msg.id, payload: respData };
|
||||
ws.send(JSON.stringify(resp));
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
const err: MessageEnvelope = { type: 'error', payload: { message: 'Invalid message' } };
|
||||
ws.send(JSON.stringify(err));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user