diff --git a/package-lock.json b/package-lock.json index c4b2e0e..ce0a676 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "react-dom": "19.2.0", "react-hook-form": "^7.65.0", "react-icons": "^5.5.0", + "socket.io-client": "^4.8.3", "yup": "^1.7.1" }, "devDependencies": { @@ -147,7 +148,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -557,7 +557,6 @@ "resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-3.31.0.tgz", "integrity": "sha512-puvrZOfnfMA+DckDcz0UxO20l7TVhwsdQ9ksCv4nIUB430yuWzon0yo9fM10lEr3hd7BhjZARpMCVw5u280clw==", "license": "MIT", - "peer": true, "dependencies": { "@ark-ui/react": "^5.29.1", "@emotion/is-prop-valid": "^1.4.0", @@ -693,7 +692,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1986,7 +1984,6 @@ "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz", "integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/helpers": "^0.5.0" } @@ -2626,6 +2623,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -2841,7 +2844,6 @@ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.8.0" } @@ -2880,7 +2882,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz", "integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.90.16" }, @@ -3032,7 +3033,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3092,7 +3092,6 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -4520,7 +4519,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4886,7 +4884,6 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -4993,7 +4990,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5494,6 +5490,49 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5746,7 +5785,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5948,7 +5986,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7894,7 +7931,6 @@ "integrity": "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg==", "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "16.0.0", "@swc/helpers": "0.5.15", @@ -8558,7 +8594,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz", "integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -8682,7 +8717,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8692,7 +8726,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8705,7 +8738,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.0.tgz", "integrity": "sha512-oFDt/iIFMV9ZfV52waONXzg4xuSlbwKUPvXVH2jumL1me5qFhBMc4knZxuXiZ2+j6h546sYe3ZKJcg/900/iHw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -9239,6 +9271,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -9618,7 +9678,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9799,7 +9858,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10208,6 +10266,14 @@ } } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -10255,7 +10321,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 0fb3879..7a139da 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "react-dom": "19.2.0", "react-hook-form": "^7.65.0", "react-icons": "^5.5.0", + "socket.io-client": "^4.8.3", "yup": "^1.7.1" }, "devDependencies": { diff --git a/src/hooks/useSocket.ts b/src/hooks/useSocket.ts new file mode 100644 index 0000000..7902a86 --- /dev/null +++ b/src/hooks/useSocket.ts @@ -0,0 +1,166 @@ +import { useEffect, useRef, useCallback, useState } from 'react'; +import { io, Socket } from 'socket.io-client'; +import { useQueryClient } from '@tanstack/react-query'; +import { ProjectsQueryKeys } from '@/lib/api/skriptai'; + +// Event types (mirrors backend ws-events.ts) +export const WS_EVENTS = { + JOB_PROGRESS: 'job:progress', + JOB_COMPLETED: 'job:completed', + JOB_FAILED: 'job:failed', + SEGMENT_GENERATED: 'segment:generated', + SEGMENT_UPDATED: 'segment:updated', + VERSION_CREATED: 'version:created', + VERSION_RESTORED: 'version:restored', + PROJECT_STATUS_CHANGED: 'project:status-changed', +} as const; + +export interface JobProgressPayload { + jobId: string; + jobType: string; + projectId: string; + step: number; + totalSteps: number; + message: string; + percentage: number; +} + +export interface JobCompletedPayload { + jobId: string; + jobType: string; + projectId: string; + result?: any; +} + +export interface JobFailedPayload { + jobId: string; + jobType: string; + projectId: string; + reason: string; +} + +interface UseSocketOptions { + projectId: string; + onProgress?: (payload: JobProgressPayload) => void; + onCompleted?: (payload: JobCompletedPayload) => void; + onFailed?: (payload: JobFailedPayload) => void; + autoInvalidate?: boolean; +} + +/** + * useSocket — Real-time WebSocket hook for SkriptAI + * + * Connects to the Socket.IO namespace, joins a project room, + * and listens for job progress, completion, and failure events. + * + * Usage: + * ```tsx + * const { isConnected, latestProgress } = useSocket({ + * projectId: 'abc123', + * onProgress: (p) => console.log(p.message), + * onCompleted: () => refetch(), + * }); + * ``` + */ +export function useSocket({ + projectId, + onProgress, + onCompleted, + onFailed, + autoInvalidate = true, +}: UseSocketOptions) { + const socketRef = useRef(null); + const queryClient = useQueryClient(); + const [isConnected, setIsConnected] = useState(false); + const [latestProgress, setLatestProgress] = + useState(null); + + const connect = useCallback(() => { + if (socketRef.current?.connected) return; + + // Determine WS URL from the API base or default + const wsUrl = + process.env.NEXT_PUBLIC_SKRIPTAI_WS_URL || + process.env.NEXT_PUBLIC_SKRIPTAI_API_URL?.replace(/\/api$/, '') || + 'http://localhost:3001'; + + const socket = io(`${wsUrl}/skriptai`, { + transports: ['websocket', 'polling'], + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 2000, + }); + + socket.on('connect', () => { + setIsConnected(true); + socket.emit('join:project', projectId); + }); + + socket.on('disconnect', () => { + setIsConnected(false); + }); + + // Job progress + socket.on(WS_EVENTS.JOB_PROGRESS, (payload: JobProgressPayload) => { + setLatestProgress(payload); + onProgress?.(payload); + }); + + // Job completed + socket.on(WS_EVENTS.JOB_COMPLETED, (payload: JobCompletedPayload) => { + setLatestProgress(null); + onCompleted?.(payload); + + if (autoInvalidate) { + queryClient.invalidateQueries({ + queryKey: ProjectsQueryKeys.detail(projectId), + }); + } + }); + + // Job failed + socket.on(WS_EVENTS.JOB_FAILED, (payload: JobFailedPayload) => { + setLatestProgress(null); + onFailed?.(payload); + }); + + // Version events → auto-invalidate + socket.on(WS_EVENTS.VERSION_CREATED, () => { + if (autoInvalidate) { + queryClient.invalidateQueries({ + queryKey: ProjectsQueryKeys.detail(projectId), + }); + } + }); + + socket.on(WS_EVENTS.VERSION_RESTORED, () => { + if (autoInvalidate) { + queryClient.invalidateQueries({ + queryKey: ProjectsQueryKeys.detail(projectId), + }); + } + }); + + socketRef.current = socket; + }, [projectId, onProgress, onCompleted, onFailed, autoInvalidate, queryClient]); + + useEffect(() => { + connect(); + + return () => { + if (socketRef.current) { + socketRef.current.emit('leave:project', projectId); + socketRef.current.disconnect(); + socketRef.current = null; + setIsConnected(false); + setLatestProgress(null); + } + }; + }, [connect, projectId]); + + return { + isConnected, + latestProgress, + socket: socketRef.current, + }; +} diff --git a/src/lib/api/skriptai/index.ts b/src/lib/api/skriptai/index.ts index 1eaaa8f..6456ae0 100644 --- a/src/lib/api/skriptai/index.ts +++ b/src/lib/api/skriptai/index.ts @@ -80,6 +80,13 @@ export { VersionsQueryKeys, } from './versions/use-hooks'; +// Services - Jobs +export { jobsService } from './jobs/service'; +export type { SubmitJobRequest, SubmitJobResponse, JobStatusResponse, JobResultResponse } from './jobs/service'; + +// Hooks - Jobs +export { useSubmitJob, useJobPolling } from './jobs/use-hooks'; + // Types - Projects export type { CreateProjectDto, diff --git a/src/lib/api/skriptai/jobs/service.ts b/src/lib/api/skriptai/jobs/service.ts new file mode 100644 index 0000000..5c16aa4 --- /dev/null +++ b/src/lib/api/skriptai/jobs/service.ts @@ -0,0 +1,72 @@ +import { apiRequest } from '@/lib/api/api-service'; +import { ApiResponse } from '@/types/api-response'; + +/** + * Jobs Service - SkriptAI + * Matches Backend: /api/skriptai/jobs/* + */ + +export interface SubmitJobRequest { + type: string; + payload: Record; +} + +export interface SubmitJobResponse { + jobId: string; + type: string; + status: string; +} + +export interface JobStatusResponse { + jobId: string; + type: string; + status: 'QUEUED' | 'PROCESSING' | 'COMPLETED' | 'FAILED'; + progress: { + step: number; + totalSteps: number; + message: string; + percentage: number; + } | null; + createdAt: string; + processedOn: string | null; + finishedOn: string | null; + failedReason: string | null; +} + +export interface JobResultResponse { + jobId: string; + status: string; + result: any; + message?: string; +} + +const submitJob = (data: SubmitJobRequest) => { + return apiRequest>({ + url: '/skriptai/jobs/submit', + client: 'skriptai', + method: 'post', + data, + }); +}; + +const getStatus = (jobId: string) => { + return apiRequest>({ + url: `/skriptai/jobs/${jobId}/status`, + client: 'skriptai', + method: 'get', + }); +}; + +const getResult = (jobId: string) => { + return apiRequest>({ + url: `/skriptai/jobs/${jobId}/result`, + client: 'skriptai', + method: 'get', + }); +}; + +export const jobsService = { + submitJob, + getStatus, + getResult, +}; diff --git a/src/lib/api/skriptai/jobs/use-hooks.ts b/src/lib/api/skriptai/jobs/use-hooks.ts new file mode 100644 index 0000000..3062793 --- /dev/null +++ b/src/lib/api/skriptai/jobs/use-hooks.ts @@ -0,0 +1,100 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { ApiResponse } from '@/types/api-response'; +import { + jobsService, + SubmitJobRequest, + SubmitJobResponse, + JobStatusResponse, +} from './service'; + +/** + * useSubmitJob — Submit a new async job + */ +export function useSubmitJob() { + const { data, ...rest } = useMutation< + ApiResponse, + Error, + SubmitJobRequest + >({ + mutationFn: (jobData) => jobsService.submitJob(jobData), + }); + + return { data: data?.data, ...rest }; +} + +/** + * useJobPolling — Poll a job for status updates + * + * @param jobId - Job ID to poll (null to disable) + * @param intervalMs - Polling interval in ms (default: 2000) + * @param onComplete - Callback when job completes + * @param onFail - Callback when job fails + */ +export function useJobPolling( + jobId: string | null, + intervalMs: number = 2000, + onComplete?: (result: any) => void, + onFail?: (reason: string) => void, +) { + const [status, setStatus] = useState(null); + const [isPolling, setIsPolling] = useState(false); + const intervalRef = useRef | null>(null); + + const stopPolling = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + setIsPolling(false); + }, []); + + const pollOnce = useCallback(async () => { + if (!jobId) return; + + try { + const response = await jobsService.getStatus(jobId); + const jobStatus = response.data; + setStatus(jobStatus); + + if (jobStatus.status === 'COMPLETED') { + stopPolling(); + if (onComplete) { + const resultResp = await jobsService.getResult(jobId); + onComplete(resultResp.data?.result); + } + } else if (jobStatus.status === 'FAILED') { + stopPolling(); + if (onFail) { + onFail(jobStatus.failedReason || 'Unknown error'); + } + } + } catch (error) { + // Silently retry on network errors + console.warn('Job polling error:', error); + } + }, [jobId, onComplete, onFail, stopPolling]); + + useEffect(() => { + if (!jobId) { + stopPolling(); + setStatus(null); + return; + } + + setIsPolling(true); + pollOnce(); // Poll immediately + intervalRef.current = setInterval(pollOnce, intervalMs); + + return () => { + stopPolling(); + }; + }, [jobId, intervalMs, pollOnce, stopPolling]); + + return { + status, + isPolling, + stopPolling, + progress: status?.progress || null, + }; +}