Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions frontend/src/models/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,46 @@ export type WSAction =
| 'create_label'
| 'update_label'
| 'delete_label'
| 'get_tasks'
| 'get_completed_tasks'
| 'get_task'
| 'create_task'
| 'update_task'
| 'delete_task'
| 'skip_task'
| 'update_due_date'
| 'complete_task'
| 'uncomplete_task'
| 'get_task_history'
| 'get_app_tokens'
| 'create_app_token'
| 'delete_app_token'
| 'update_notification_settings'
| 'get_user_profile'
| 'update_password'

export interface WSActionPayloads {
get_user_labels: void
create_label: Omit<Label, 'id'>
update_label: Label
delete_label: { id: number }
get_tasks: void
get_completed_tasks: { limit?: number; page?: number }
get_task: { id: number }
create_task: Omit<Task, 'id'>
update_task: Task
delete_task: { id: number }
skip_task: { id: number }
update_due_date: { id: number; due_date: string }
complete_task: { id: number }
uncomplete_task: { id: number }
get_task_history: { id: number }
get_app_tokens: void
create_app_token: { name: string; scopes: string[]; expiration: number }
delete_app_token: { id: string }
update_notification_settings: { provider: NotificationType; triggers: NotificationTriggerOptions }
get_user_profile: void
update_password: { password: string }
}

export type WSEvent =
Expand Down
119 changes: 118 additions & 1 deletion frontend/src/utils/api.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import { RefreshToken } from '@/api/auth'
import { TOKEN_REFRESH_THRESHOLD_MS } from '@/constants/time'
import WebSocketManager from './websocket'
import { store } from '@/store/store'
import { WSAction } from '@/models/websocket'

const API_URL = import.meta.env.VITE_APP_API_URL
const HTTP_STATUS_NO_CONTENT = 204

type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'

type FailureResponse = {
error: string
}

interface RequestToWSMapping {
action: WSAction
getData: (url: string, body: unknown) => any
}

let isRefreshingAccessToken = false
const isTokenNearExpiration = () => {
const now = new Date()
Expand All @@ -17,6 +26,82 @@ const isTokenNearExpiration = () => {
return now.getTime() + TOKEN_REFRESH_THRESHOLD_MS > expire.getTime()
}

// Map HTTP requests to WebSocket actions
const mapRequestToWebSocket = (
url: string,
method: RequestMethod
): RequestToWSMapping | null => {
// Extract ID from URL if present
const idMatch = url.match(/\/(\d+)/)
const id = idMatch ? parseInt(idMatch[1], 10) : undefined

// Labels endpoints
if (url === '/labels' && method === 'GET') {
return { action: 'get_user_labels', getData: () => undefined }
}
if (url === '/labels' && method === 'POST') {
return { action: 'create_label', getData: (_url, body) => body }
}
if (url === '/labels' && method === 'PUT') {
return { action: 'update_label', getData: (_url, body) => body }
}
if (url.startsWith('/labels/') && method === 'DELETE' && id) {
return { action: 'delete_label', getData: () => ({ id }) }
}

// Tasks endpoints
if (url === '/tasks/' && method === 'GET') {
return { action: 'get_tasks', getData: () => undefined }
}
if (url === '/tasks/completed' && method === 'GET') {
return { action: 'get_completed_tasks', getData: () => ({}) }
}
if (url === '/tasks/' && method === 'POST') {
return { action: 'create_task', getData: (_url, body) => body }
}
if (url === '/tasks/' && method === 'PUT') {
return { action: 'update_task', getData: (_url, body) => body }
}
if (url.startsWith('/tasks/') && url.endsWith('/do') && method === 'POST' && id) {
return { action: 'complete_task', getData: () => ({ id }) }
}
if (url.startsWith('/tasks/') && url.endsWith('/skip') && method === 'POST' && id) {
return { action: 'skip_task', getData: () => ({ id }) }
}
if (url.startsWith('/tasks/') && url.endsWith('/dueDate') && method === 'PUT' && id) {
return { action: 'update_due_date', getData: (_url, body: any) => ({ id, due_date: body.due_date }) }
}
if (url.startsWith('/tasks/') && url.endsWith('/history') && method === 'GET' && id) {
return { action: 'get_task_history', getData: () => ({ id }) }
}
if (url.startsWith('/tasks/') && method === 'DELETE' && id) {
return { action: 'delete_task', getData: () => ({ id }) }
}

// User/Token endpoints
if (url === '/users/tokens' && method === 'GET') {
return { action: 'get_app_tokens', getData: () => undefined }
}
if (url === '/users/tokens' && method === 'POST') {
return { action: 'create_app_token', getData: (_url, body) => body }
}
if (url.startsWith('/users/tokens/') && method === 'DELETE') {
const tokenId = url.split('/').pop()
return { action: 'delete_app_token', getData: () => ({ id: tokenId }) }
}
if (url === '/users/profile' && method === 'GET') {
return { action: 'get_user_profile', getData: () => undefined }
}
if (url === '/users/notifications' && method === 'PUT') {
return { action: 'update_notification_settings', getData: (_url, body) => body }
}
if (url === '/users/change_password' && method === 'PUT') {
return { action: 'update_password', getData: (_url, body) => body }
}

return null
}

export const isTokenValid = (): boolean => {
const token = localStorage.getItem('ca_token')
if (token) {
Expand Down Expand Up @@ -67,6 +152,39 @@ export async function Request<SuccessfulResponse>(
throw new Error('User is not authenticated')
}

// Check if WebSocket should be used
const state = store.getState()
const useWebSocket = state.featureFlags.useWebsockets && requiresAuth

if (useWebSocket) {
const wsManager = WebSocketManager.getInstance()
if (wsManager.isConnected()) {
const wsMapping = mapRequestToWebSocket(url, method)

if (wsMapping) {
try {
const wsData = wsMapping.getData(url, body)
const response = await wsManager.sendRequest(wsMapping.action, wsData)

if (response.status === HTTP_STATUS_NO_CONTENT) {
return {} as SuccessfulResponse
}

if (response.status >= 400) {
const errorData = response.data as FailureResponse
throw new Error(errorData.error || 'Request failed')
}

return response.data as SuccessfulResponse
} catch (error) {
// If WebSocket request fails, fall back to HTTP
console.debug('WebSocket request failed, falling back to HTTP:', error)
}
}
}
}

// Fall back to HTTP request
const fullURL = `${API_URL}/api/v1${url}`

const headers: HeadersInit = {
Expand All @@ -89,7 +207,6 @@ export async function Request<SuccessfulResponse>(
}

const response: Response = await fetch(fullURL, options)
const HTTP_STATUS_NO_CONTENT = 204
if (response.status === HTTP_STATUS_NO_CONTENT) {
return {} as SuccessfulResponse
}
Expand Down
64 changes: 64 additions & 0 deletions frontend/src/utils/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
WSAction,
WSEventPayloads,
WSRequest,
WSActionPayloads,
} from '@/models/websocket'
import { store } from '@/store/store'
import { wsConnecting, wsConnected, wsDisconnected } from '@/store/wsSlice'
Expand Down Expand Up @@ -200,6 +201,69 @@ export class WebSocketManager {
this.socket!.send(JSON.stringify(request))
}

async sendRequest<T extends WSAction>(
action: T,
data?: WSActionPayloads[T],
timeout: number = 5000
): Promise<{ status: number; data: any }> {
if (!this.isConnected()) {
throw new Error('WebSocket is not connected')
}

// Generate a unique request ID using crypto API if available, fallback to timestamp-based ID
const requestId = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`

const request: WSRequest<T> = {
requestId,
action,
data,
}

return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
cleanup()
reject(new Error('Request timed out'))
}, timeout)

const handleMessage = (event: MessageEvent) => {
try {
const response = JSON.parse(event.data)
// Validate response structure before accessing properties
if (
response &&
typeof response === 'object' &&
'requestId' in response &&
'status' in response &&
'data' in response &&
response.requestId === requestId
) {
cleanup()
resolve({ status: response.status, data: response.data })
}
} catch {
// Ignore parsing errors for messages not meant for us
}
}

const cleanup = () => {
clearTimeout(timeoutId)
if (this.socket) {
this.socket.removeEventListener('message', handleMessage)
}
}

if (this.socket) {
this.socket.addEventListener('message', handleMessage)
this.socket.send(JSON.stringify(request))
} else {
cleanup()
reject(new Error('WebSocket is not connected'))
}
})
}

private scheduleReconnect() {
if (this.manualClose || !this.enabled) {
return
Expand Down
15 changes: 15 additions & 0 deletions frontend/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,11 @@
resolved "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.1.tgz"
integrity sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==

"@rollup/[email protected]":
version "4.45.1"
resolved "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.1.tgz"
integrity sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==

"@sinclair/typebox@^0.34.0":
version "0.34.41"
resolved "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz"
Expand Down Expand Up @@ -1118,6 +1123,11 @@
resolved "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.13.0.tgz"
integrity sha512-51n4P4nv6rblXyH3zCEktvmR9uSAZ7+zbfeby0sxbj8LS/IKuVd7iCwD5dwMj4CxG9Fs+HgjN73dLQF/OerHhg==

"@swc/[email protected]":
version "1.13.0"
resolved "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.13.0.tgz"
integrity sha512-VMqelgvnXs27eQyhDf1S2O2MxSdchIH7c1tkxODRtu9eotcAeniNNgqqLjZ5ML0MGeRk/WpbsAY/GWi7eSpiHw==

"@swc/core@^1.12.11":
version "1.13.0"
resolved "https://registry.npmjs.org/@swc/core/-/core-1.13.0.tgz"
Expand Down Expand Up @@ -1450,6 +1460,11 @@
resolved "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz"
integrity sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==

"@unrs/[email protected]":
version "1.11.1"
resolved "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz"
integrity sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==

"@vitejs/plugin-react-swc@^3.7.2":
version "3.11.0"
resolved "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz"
Expand Down
Loading