Skip to content
Merged
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
1 change: 1 addition & 0 deletions LLMD/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
136 changes: 136 additions & 0 deletions LLMD/resources/live-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,8 +487,144 @@ export class MyComponent extends LiveComponent<State> {
- 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<LiveUploadState> {
static defaultState = defaultState

constructor(initialState: Partial<typeof defaultState>, 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 (
<LiveUploadWidget live={live} />
)
}
```

### 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)
130 changes: 130 additions & 0 deletions LLMD/resources/live-upload.md
Original file line number Diff line number Diff line change
@@ -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<LiveUploadState> {
static defaultState = defaultState

constructor(initialState: Partial<typeof defaultState>, 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 (
<LiveUploadWidget live={live} />
)
}
```

## 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`
28 changes: 28 additions & 0 deletions app/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>('')
const [isLoading, setIsLoading] = useState(false)
Expand Down Expand Up @@ -182,6 +184,25 @@ function AppContent() {
)
}


if (showUpload) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex flex-col items-center justify-center px-4">
<div className="mb-8">
<button
onClick={() => setShowUpload(false)}
className="px-4 py-2 bg-white/10 backdrop-blur-sm border border-white/20 text-white rounded-lg font-medium hover:bg-white/20 transition-all"
>
&larr; Voltar
</button>
</div>
<UploadDemo />
</div>
)
}



return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
<div className="flex flex-col items-center justify-center min-h-screen px-6 text-center">
Expand Down Expand Up @@ -260,6 +281,13 @@ function AppContent() {
>
🧪 Test API
</button>

<button
onClick={() => setShowUpload(true)}
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-500 to-cyan-600 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-cyan-500/50 transition-all"
>
Live Upload
</button>
<a
href="/swagger"
target="_blank"
Expand Down
Loading
Loading