diff --git a/LLMD/INDEX.md b/LLMD/INDEX.md index 11de731e..0c17a730 100644 --- a/LLMD/INDEX.md +++ b/LLMD/INDEX.md @@ -27,6 +27,7 @@ - [Routes with Eden Treaty](resources/routes-eden.md) - Type-safe API routes - [Controllers & Services](resources/controllers.md) - Business logic patterns - [Live Components](resources/live-components.md) - WebSocket components +- [Live Upload](resources/live-upload.md) - Chunked upload via Live Components - [External Plugins](resources/plugins-external.md) - Plugin development ## Patterns & Rules diff --git a/LLMD/resources/live-components.md b/LLMD/resources/live-components.md index 58225103..3fb7b12b 100644 --- a/LLMD/resources/live-components.md +++ b/LLMD/resources/live-components.md @@ -487,8 +487,144 @@ export class MyComponent extends LiveComponent { - Store non-serializable data in state - Forget to cleanup in destroy method +--- + +## Live Upload (Chunked Upload via WebSocket) + +This project includes a Live Component-based upload system that streams file chunks +over the Live Components WebSocket. The client uses a chunked upload hook; the server +tracks progress and assembles the file in `uploads/`. + +### Server: LiveUpload Component + +Create server-side upload actions inside a Live Component. This example is the base +implementation used by the demos: + +```typescript +// app/server/live/LiveUpload.ts +import { LiveComponent } from '@core/types/types' +import { liveUploadDefaultState, type LiveUploadState } from '@app/shared' + +export const defaultState: LiveUploadState = liveUploadDefaultState + +export class LiveUpload extends LiveComponent { + static defaultState = defaultState + + constructor(initialState: Partial, ws: any, options?: { room?: string; userId?: string }) { + super({ ...defaultState, ...initialState }, ws, options) + } + + async startUpload(payload: { fileName: string; fileSize: number; fileType: string }) { + // Basic validation (example) + const normalized = payload.fileName.toLowerCase() + if (normalized.includes('..') || normalized.includes('/') || normalized.includes('\\')) { + throw new Error('Invalid file name') + } + + const ext = normalized.includes('.') ? normalized.split('.').pop() || '' : '' + const blocked = ['exe', 'bat', 'cmd', 'sh', 'ps1', 'msi', 'jar'] + if (ext && blocked.includes(ext)) { + throw new Error(`File extension not allowed: .${ext}`) + } + + this.setState({ + status: 'uploading', + progress: 0, + fileName: payload.fileName, + fileSize: payload.fileSize, + fileType: payload.fileType, + fileUrl: '', + bytesUploaded: 0, + totalBytes: payload.fileSize, + error: null + }) + + return { success: true } + } + + async updateProgress(payload: { progress: number; bytesUploaded: number; totalBytes: number }) { + const progress = Math.max(0, Math.min(100, payload.progress)) + this.setState({ + progress, + bytesUploaded: payload.bytesUploaded, + totalBytes: payload.totalBytes + }) + + return { success: true, progress } + } + + async completeUpload(payload: { fileUrl: string }) { + this.setState({ + status: 'complete', + progress: 100, + fileUrl: payload.fileUrl, + error: null + }) + + return { success: true } + } + + async failUpload(payload: { error: string }) { + this.setState({ + status: 'error', + error: payload.error || 'Upload failed' + }) + + return { success: true } + } + + async reset() { + this.setState({ ...defaultState }) + return { success: true } + } +} +``` + +### Client: useLiveUpload + Widget + +Use the client hook and UI widget to wire the upload to the Live Component: + +```typescript +// app/client/src/live/UploadDemo.tsx +import { useLiveUpload } from './useLiveUpload' +import { LiveUploadWidget } from '../components/LiveUploadWidget' + +export function UploadDemo() { + const { live } = useLiveUpload() + + return ( + + ) +} +``` + +### Chunked Upload Flow + +1. Client calls `startUpload()` (Live Component action). +2. Client streams file chunks over WebSocket with `useChunkedUpload`. +3. Server assembles file in `uploads/` and returns `/uploads/...`. +4. Client maps to `/api/uploads/...` for access. + +### Error Handling + +- If an action throws, the error surfaces in `live.$error` on the client. +- The widget shows `localError || state.error || $error`. + +### Files Involved + +**Server** +- `app/server/live/LiveUpload.ts` +- `core/server/live/FileUploadManager.ts` (chunk handling + file assembly) +- `core/server/live/websocket-plugin.ts` (upload message routing) + +**Client** +- `core/client/hooks/useChunkedUpload.ts` (streaming chunks) +- `core/client/hooks/useLiveUpload.ts` (Live Component wrapper) +- `app/client/src/components/LiveUploadWidget.tsx` (UI) + ## Related - [Project Structure](../patterns/project-structure.md) - [Type Safety Patterns](../patterns/type-safety.md) - [WebSocket Plugin](../core/plugin-system.md) +- [Live Upload](./live-upload.md) diff --git a/LLMD/resources/live-upload.md b/LLMD/resources/live-upload.md new file mode 100644 index 00000000..e73aad67 --- /dev/null +++ b/LLMD/resources/live-upload.md @@ -0,0 +1,130 @@ +# Live Upload (Chunked Upload via WebSocket) + +**Version:** 1.11.0 | **Updated:** 2026-02-08 + +## Overview + +FluxStack supports chunked file upload over the Live Components WebSocket. The +server tracks progress and assembles the file in `uploads/`. The client streams +chunks without loading the entire file into memory. + +## Server: LiveUpload Component + +```typescript +// app/server/live/LiveUpload.ts +import { LiveComponent } from '@core/types/types' +import { liveUploadDefaultState, type LiveUploadState } from '@app/shared' + +export const defaultState: LiveUploadState = liveUploadDefaultState + +export class LiveUpload extends LiveComponent { + static defaultState = defaultState + + constructor(initialState: Partial, ws: any, options?: { room?: string; userId?: string }) { + super({ ...defaultState, ...initialState }, ws, options) + } + + async startUpload(payload: { fileName: string; fileSize: number; fileType: string }) { + const normalized = payload.fileName.toLowerCase() + if (normalized.includes('..') || normalized.includes('/') || normalized.includes('\\')) { + throw new Error('Invalid file name') + } + + const ext = normalized.includes('.') ? normalized.split('.').pop() || '' : '' + const blocked = ['exe', 'bat', 'cmd', 'sh', 'ps1', 'msi', 'jar'] + if (ext && blocked.includes(ext)) { + throw new Error(`File extension not allowed: .${ext}`) + } + + this.setState({ + status: 'uploading', + progress: 0, + fileName: payload.fileName, + fileSize: payload.fileSize, + fileType: payload.fileType, + fileUrl: '', + bytesUploaded: 0, + totalBytes: payload.fileSize, + error: null + }) + + return { success: true } + } + + async updateProgress(payload: { progress: number; bytesUploaded: number; totalBytes: number }) { + const progress = Math.max(0, Math.min(100, payload.progress)) + this.setState({ + progress, + bytesUploaded: payload.bytesUploaded, + totalBytes: payload.totalBytes + }) + + return { success: true, progress } + } + + async completeUpload(payload: { fileUrl: string }) { + this.setState({ + status: 'complete', + progress: 100, + fileUrl: payload.fileUrl, + error: null + }) + + return { success: true } + } + + async failUpload(payload: { error: string }) { + this.setState({ + status: 'error', + error: payload.error || 'Upload failed' + }) + + return { success: true } + } + + async reset() { + this.setState({ ...defaultState }) + return { success: true } + } +} +``` + +## Client: useLiveUpload + Widget + +```typescript +// app/client/src/live/UploadDemo.tsx +import { useLiveUpload } from './useLiveUpload' +import { LiveUploadWidget } from '../components/LiveUploadWidget' + +export function UploadDemo() { + const { live } = useLiveUpload() + + return ( + + ) +} +``` + +## Chunked Upload Flow + +1. Client calls `startUpload()` (Live Component action). +2. Client streams file chunks over WebSocket with `useChunkedUpload`. +3. Server assembles file in `uploads/` and returns `/uploads/...`. +4. Client maps to `/api/uploads/...` for access. + +## Error Handling + +- If an action throws, the error surfaces in `live.$error` on the client. +- The widget shows `localError || state.error || $error`. + +## Files Involved + +**Server** +- `app/server/live/LiveUpload.ts` +- `core/server/live/FileUploadManager.ts` +- `core/server/live/websocket-plugin.ts` + +**Client** +- `core/client/hooks/useChunkedUpload.ts` +- `core/client/hooks/useLiveUpload.ts` +- `app/client/src/components/LiveUploadWidget.tsx` diff --git a/app/client/src/App.tsx b/app/client/src/App.tsx index 32345907..496a6984 100644 --- a/app/client/src/App.tsx +++ b/app/client/src/App.tsx @@ -4,11 +4,13 @@ import { FaFire, FaBook, FaGithub } from 'react-icons/fa' import { LiveComponentsProvider } from '@/core/client' import { FormDemo } from './live/FormDemo' import { CounterDemo } from './live/CounterDemo' +import { UploadDemo } from './live/UploadDemo' function AppContent() { const [apiStatus, setApiStatus] = useState<'checking' | 'online' | 'offline'>('checking') const [showForm, setShowForm] = useState(false) const [showCounter, setShowCounter] = useState(false) + const [showUpload, setShowUpload] = useState(false) const [showApiTest, setShowApiTest] = useState(false) const [apiResponse, setApiResponse] = useState('') const [isLoading, setIsLoading] = useState(false) @@ -182,6 +184,25 @@ function AppContent() { ) } + + if (showUpload) { + return ( +
+
+ +
+ +
+ ) + } + + + return (
@@ -260,6 +281,13 @@ function AppContent() { > 🧪 Test API + + Promise + updateProgress: (payload: { progress: number; bytesUploaded: number; totalBytes: number }) => Promise + completeUpload: (payload: { fileUrl: string }) => Promise + failUpload: (payload: { error: string }) => Promise + reset: () => Promise +} + +export interface LiveUploadWidgetProps { + live: LiveUploadActions + title?: string + description?: string + allowPreview?: boolean + options?: LiveChunkedUploadOptions + onComplete?: (response: FileUploadCompleteResponse) => void +} + +export function LiveUploadWidget({ + live, + title = 'Upload em Chunks', + description = 'Envio via WebSocket com Live Components e reatividade server-side.', + allowPreview = true, + options, + onComplete +}: LiveUploadWidgetProps) { + // live is expected to be a LiveUpload-compatible component + const [selectedFile, setSelectedFile] = useState(null) + const [localError, setLocalError] = useState(null) + const [previewUrl, setPreviewUrl] = useState(null) + + const mergedOptions = useMemo(() => { + return { + allowedTypes: [], + maxFileSize: 500 * 1024 * 1024, + adaptiveChunking: true, + fileUrlResolver: (fileUrl) => fileUrl.startsWith('/uploads/') ? `/api${fileUrl}` : fileUrl, + onComplete, + ...options + } + }, [options, onComplete]) + + const { + uploading, + bytesUploaded, + totalBytes, + uploadFile, + cancelUpload, + reset + } = useLiveChunkedUpload(live, mergedOptions) + + const canUpload = live.$connected && !!live.$componentId && !uploading + + const handleSelectFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] ?? null + setSelectedFile(file) + setLocalError(null) + + if (previewUrl) { + URL.revokeObjectURL(previewUrl) + setPreviewUrl(null) + } + + if (allowPreview && file && file.type.startsWith('image/')) { + setPreviewUrl(URL.createObjectURL(file)) + } + } + + useEffect(() => { + return () => { + if (previewUrl) { + URL.revokeObjectURL(previewUrl) + } + } + }, [previewUrl]) + + const handleStartUpload = async () => { + if (!selectedFile) { + setLocalError('Selecione um arquivo primeiro.') + return + } + + if (!live.$connected || !live.$componentId) { + setLocalError('WebSocket ainda nao conectou. Tente novamente em alguns segundos.') + return + } + + setLocalError(null) + await uploadFile(selectedFile) + } + + const handleReset = async () => { + setSelectedFile(null) + setLocalError(null) + if (previewUrl) { + URL.revokeObjectURL(previewUrl) + setPreviewUrl(null) + } + await reset() + } + + const resolvedUrl = live.$state.fileUrl + + return ( +
+

+ {title} +

+ +

+ {description} +

+ +
+ ) +} diff --git a/app/client/src/live/UploadDemo.tsx b/app/client/src/live/UploadDemo.tsx new file mode 100644 index 00000000..b0db8ef8 --- /dev/null +++ b/app/client/src/live/UploadDemo.tsx @@ -0,0 +1,21 @@ +import { useState } from 'react' +import { LiveUploadWidget } from '../components/LiveUploadWidget' +import { useLiveUpload } from '@/core/client' + +export function UploadDemo() { + const [lastUrl, setLastUrl] = useState(null) + const { live } = useLiveUpload({ + onComplete: (response) => setLastUrl(response.fileUrl || null) + }) + + return ( +
+ + {lastUrl && ( +
+ Ultimo arquivo: {lastUrl} +
+ )} +
+ ) +} diff --git a/app/server/live/LiveUpload.ts b/app/server/live/LiveUpload.ts new file mode 100644 index 00000000..09bab983 --- /dev/null +++ b/app/server/live/LiveUpload.ts @@ -0,0 +1,98 @@ +// LiveUpload - Chunked upload state + UI sync + +import { LiveComponent } from '@core/types/types' + +type LiveUploadState = { + status: 'idle' | 'uploading' | 'complete' | 'error' + progress: number + fileName: string + fileSize: number + fileType: string + fileUrl: string + bytesUploaded: number + totalBytes: number + error: string | null +} + +export const defaultState: LiveUploadState = { + status: 'idle', + progress: 0, + fileName: '', + fileSize: 0, + fileType: '', + fileUrl: '', + bytesUploaded: 0, + totalBytes: 0, + error: null +} + +export class LiveUpload extends LiveComponent { + static defaultState = defaultState + + constructor(initialState: Partial, ws: any, options?: { room?: string; userId?: string }) { + super({ ...defaultState, ...initialState }, ws, options) + } + + async startUpload(payload: { fileName: string; fileSize: number; fileType: string }) { + const normalized = payload.fileName.toLowerCase() + if (normalized.includes('..') || normalized.includes('/') || normalized.includes('\\')) { + throw new Error('Invalid file name') + } + + const ext = normalized.includes('.') ? normalized.split('.').pop() || '' : '' + const blocked = ['exe', 'bat', 'cmd', 'sh', 'ps1', 'msi', 'jar'] + if (ext && blocked.includes(ext)) { + throw new Error(`File extension not allowed: .${ext}`) + } + + this.setState({ + status: 'uploading', + progress: 0, + fileName: payload.fileName, + fileSize: payload.fileSize, + fileType: payload.fileType, + fileUrl: '', + bytesUploaded: 0, + totalBytes: payload.fileSize, + error: null + }) + + return { success: true } + } + + async updateProgress(payload: { progress: number; bytesUploaded: number; totalBytes: number }) { + const progress = Math.max(0, Math.min(100, payload.progress)) + this.setState({ + progress, + bytesUploaded: payload.bytesUploaded, + totalBytes: payload.totalBytes + }) + + return { success: true, progress } + } + + async completeUpload(payload: { fileUrl: string }) { + this.setState({ + status: 'complete', + progress: 100, + fileUrl: payload.fileUrl, + error: null + }) + + return { success: true } + } + + async failUpload(payload: { error: string }) { + this.setState({ + status: 'error', + error: payload.error || 'Upload failed' + }) + + return { success: true } + } + + async reset() { + this.setState({ ...defaultState }) + return { success: true } + } +} diff --git a/core/client/hooks/useChunkedUpload.ts b/core/client/hooks/useChunkedUpload.ts index d778681e..3684a4a1 100644 --- a/core/client/hooks/useChunkedUpload.ts +++ b/core/client/hooks/useChunkedUpload.ts @@ -60,7 +60,7 @@ export function useChunkedUpload(componentId: string, options: ChunkedUploadOpti if (adaptiveChunking && !adaptiveSizerRef.current) { adaptiveSizerRef.current = new AdaptiveChunkSizer({ initialChunkSize: chunkSize, - minChunkSize: 16 * 1024, // 16KB min + minChunkSize: chunkSize, // Do not go below initial chunk size by default maxChunkSize: 1024 * 1024, // 1MB max ...adaptiveConfig }) @@ -141,24 +141,21 @@ export function useChunkedUpload(componentId: string, options: ChunkedUploadOpti console.log('✅ Upload started successfully') - // Read file as ArrayBuffer for dynamic chunking - const fileArrayBuffer = await file.arrayBuffer() - const fileData = new Uint8Array(fileArrayBuffer) - let offset = 0 let chunkIndex = 0 const estimatedTotalChunks = Math.ceil(file.size / initialChunkSize) - // Send chunks dynamically with adaptive sizing - while (offset < fileData.length) { + // Send chunks dynamically with adaptive sizing (read slice per chunk) + while (offset < file.size) { if (abortControllerRef.current?.signal.aborted) { throw new Error('Upload cancelled') } // Get current chunk size (adaptive or fixed) const currentChunkSize = adaptiveSizerRef.current?.getChunkSize() ?? chunkSize - const chunkEnd = Math.min(offset + currentChunkSize, fileData.length) - const chunkBytes = fileData.slice(offset, chunkEnd) + const chunkEnd = Math.min(offset + currentChunkSize, file.size) + const sliceBuffer = await file.slice(offset, chunkEnd).arrayBuffer() + const chunkBytes = new Uint8Array(sliceBuffer) // Convert chunk to base64 let binary = '' @@ -306,4 +303,4 @@ export function useChunkedUpload(componentId: string, options: ChunkedUploadOpti cancelUpload, reset } -} \ No newline at end of file +} diff --git a/core/client/hooks/useLiveChunkedUpload.ts b/core/client/hooks/useLiveChunkedUpload.ts new file mode 100644 index 00000000..78c64b69 --- /dev/null +++ b/core/client/hooks/useLiveChunkedUpload.ts @@ -0,0 +1,86 @@ +import { useMemo } from 'react' +import { useLiveComponents } from '../LiveComponentsProvider' +import { useChunkedUpload } from './useChunkedUpload' +import type { ChunkedUploadOptions } from './useChunkedUpload' +import type { FileUploadCompleteResponse } from '@core/types/types' + +type LiveUploadActions = { + $componentId: string | null + startUpload: (payload: { fileName: string; fileSize: number; fileType: string }) => Promise + updateProgress: (payload: { progress: number; bytesUploaded: number; totalBytes: number }) => Promise + completeUpload: (payload: { fileUrl: string }) => Promise + failUpload: (payload: { error: string }) => Promise + reset: () => Promise +} + +export interface LiveChunkedUploadOptions extends Omit { + onProgress?: (progress: number, bytesUploaded: number, totalBytes: number) => void + onComplete?: (response: FileUploadCompleteResponse) => void + onError?: (error: string) => void + fileUrlResolver?: (fileUrl: string) => string +} + +export function useLiveChunkedUpload(live: LiveUploadActions, options: LiveChunkedUploadOptions = {}) { + const { sendMessageAndWait } = useLiveComponents() + + const { + onProgress, + onComplete, + onError, + fileUrlResolver, + ...chunkedOptions + } = options + + const componentId = live.$componentId ?? '' + + const base = useChunkedUpload(componentId, { + ...chunkedOptions, + sendMessageAndWait, + onProgress: (pct, uploaded, total) => { + void live.updateProgress({ progress: pct, bytesUploaded: uploaded, totalBytes: total }).catch(() => {}) + onProgress?.(pct, uploaded, total) + }, + onComplete: (response) => { + const rawUrl = response.fileUrl || '' + const resolvedUrl = fileUrlResolver ? fileUrlResolver(rawUrl) : rawUrl + void live.completeUpload({ fileUrl: resolvedUrl }).catch(() => {}) + onComplete?.(response) + }, + onError: (error) => { + void live.failUpload({ error }).catch(() => {}) + onError?.(error) + } + }) + + const uploadFile = useMemo(() => { + return async (file: File) => { + if (!live.$componentId) { + const msg = 'WebSocket not ready. Wait a moment and try again.' + void live.failUpload({ error: msg }).catch(() => {}) + onError?.(msg) + return + } + + await live.startUpload({ + fileName: file.name, + fileSize: file.size, + fileType: file.type || 'application/octet-stream' + }) + + await base.uploadFile(file) + } + }, [base, live, onError]) + + const reset = useMemo(() => { + return async () => { + await live.reset() + base.reset() + } + }, [base, live]) + + return { + ...base, + uploadFile, + reset + } +} diff --git a/core/client/hooks/useLiveUpload.ts b/core/client/hooks/useLiveUpload.ts new file mode 100644 index 00000000..328a2cbb --- /dev/null +++ b/core/client/hooks/useLiveUpload.ts @@ -0,0 +1,71 @@ +import { useMemo } from 'react' +import { Live } from '../components/Live' +import { useLiveChunkedUpload } from './useLiveChunkedUpload' +import type { LiveChunkedUploadOptions } from './useLiveChunkedUpload' +import type { FileUploadCompleteResponse } from '@core/types/types' +import { LiveUpload, defaultState } from '@server/live/LiveUpload' + +export interface UseLiveUploadOptions { + live?: { + room?: string + userId?: string + autoMount?: boolean + debug?: boolean + } + upload?: LiveChunkedUploadOptions + onProgress?: (progress: number, bytesUploaded: number, totalBytes: number) => void + onComplete?: (response: FileUploadCompleteResponse) => void + onError?: (error: string) => void +} + +export function useLiveUpload(options: UseLiveUploadOptions = {}) { + const { live: liveOptions, upload: uploadOptions, onProgress, onComplete, onError } = options + + const live = Live.use(LiveUpload, { + initialState: defaultState, + ...liveOptions + }) + + const mergedUploadOptions = useMemo(() => { + return { + allowedTypes: [], + maxFileSize: 500 * 1024 * 1024, + adaptiveChunking: true, + fileUrlResolver: (fileUrl) => fileUrl.startsWith('/uploads/') ? `/api${fileUrl}` : fileUrl, + onProgress, + onComplete, + onError, + ...uploadOptions + } + }, [onProgress, onComplete, onError, uploadOptions]) + + const upload = useLiveChunkedUpload(live, mergedUploadOptions) + + const startUpload = useMemo(() => { + return async (file: File) => { + if (!live.$connected || !live.$componentId) { + const msg = 'WebSocket nao conectado. Tente novamente.' + onError?.(msg) + await live.failUpload({ error: msg }) + return + } + await upload.uploadFile(file) + } + }, [live, upload, onError]) + + return { + live, + state: live.$state, + status: live.$state.status, + connected: live.$connected, + componentId: live.$componentId, + uploading: upload.uploading, + progress: upload.progress, + bytesUploaded: upload.bytesUploaded, + totalBytes: upload.totalBytes, + error: live.$state.error, + startUpload, + cancelUpload: upload.cancelUpload, + reset: upload.reset + } +} diff --git a/core/client/index.ts b/core/client/index.ts index c4924564..ae9b4c49 100644 --- a/core/client/index.ts +++ b/core/client/index.ts @@ -22,6 +22,9 @@ export type { // Chunked Upload Hook export { useChunkedUpload } from './hooks/useChunkedUpload' export type { ChunkedUploadOptions, ChunkedUploadState } from './hooks/useChunkedUpload' +export { useLiveChunkedUpload } from './hooks/useLiveChunkedUpload' +export type { LiveChunkedUploadOptions } from './hooks/useLiveChunkedUpload' +export { useLiveUpload } from './hooks/useLiveUpload' // Live Component Hook (API principal) export { Live } from './components/Live'