checkpoint working

pull/134/head
Alex Kern 1 year ago
parent d8b0ec3cb9
commit 44f7fdeb08
No known key found for this signature in database
GPG Key ID: EF051FACCACBEE25

@ -25,8 +25,6 @@
"debug": "^4.3.6",
"express": "^4.19.2",
"fp-ts": "^2.16.9",
"framer-motion": "^3.10.6",
"immer": "^8.0.4",
"io-ts": "^2.2.21",
"ioredis": "^4.28.5",
"next": "^14.2.8",

@ -20,12 +20,6 @@ dependencies:
fp-ts:
specifier: ^2.16.9
version: 2.16.9
framer-motion:
specifier: ^3.10.6
version: 3.10.6(react-dom@18.3.1)(react@18.3.1)
immer:
specifier: ^8.0.4
version: 8.0.4
io-ts:
specifier: ^2.2.21
version: 2.2.21(fp-ts@2.16.9)
@ -1597,20 +1591,6 @@ packages:
to-fast-properties: 2.0.0
dev: false
/@emotion/is-prop-valid@0.8.8:
resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==}
requiresBuild: true
dependencies:
'@emotion/memoize': 0.7.4
dev: false
optional: true
/@emotion/memoize@0.7.4:
resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==}
requiresBuild: true
dev: false
optional: true
/@eslint/eslintrc@0.4.3:
resolution: {integrity: sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==}
engines: {node: ^10.12.0 || >=12.0.0}
@ -4814,27 +4794,6 @@ packages:
map-cache: 0.2.2
dev: false
/framer-motion@3.10.6(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-OxOtKgQS4km9a8dm0IMBtNNp4f0DiHfQ/IzxKs818+Kg9V/Ve/pRUJ2dtWBb6+W4lIPNLgRSpbOwOACVj15XcQ==}
peerDependencies:
react: '>=16.8 || ^17.0.0'
react-dom: '>=16.8 || ^17.0.0'
dependencies:
framesync: 5.2.0
hey-listen: 1.0.8
popmotion: 9.3.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
style-value-types: 4.1.1
tslib: 1.14.1
optionalDependencies:
'@emotion/is-prop-valid': 0.8.8
dev: false
/framesync@5.2.0:
resolution: {integrity: sha512-dcl92w5SHc0o6pRK3//VBVNvu6WkYkiXmHG6ZIXrVzmgh0aDYMDAaoA3p3LH71JIdN5qmhDcfONFA4Lmq22tNA==}
dev: false
/fresh@0.5.2:
resolution: {integrity: sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=}
engines: {node: '>= 0.6'}
@ -5200,10 +5159,6 @@ packages:
hermes-estree: 0.23.0
dev: false
/hey-listen@1.0.8:
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
dev: false
/hoek@2.16.3:
resolution: {integrity: sha512-V6Yw1rIcYV/4JsnggjBU0l4Kr+EXhpwqXRusENU1Xx6ro00IHPHYNynCuBTOZAPlr3AAmLvchH9I7N/VUdvOwQ==}
engines: {node: '>=0.10.40'}
@ -5291,10 +5246,6 @@ packages:
queue: 6.0.2
dev: false
/immer@8.0.4:
resolution: {integrity: sha512-jMfL18P+/6P6epANRvRk6q8t+3gGhqsJ9EuJ25AXE+9bNTYtssvzeYbEd0mXRYWCmmXSIbnlpz6vd6iJlmGGGQ==}
dev: false
/import-fresh@2.0.0:
resolution: {integrity: sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg==}
engines: {node: '>=4'}
@ -7298,15 +7249,6 @@ packages:
semver-compare: 1.0.0
dev: true
/popmotion@9.3.1:
resolution: {integrity: sha512-Qozvg8rz2OGeZwWuIjqlSXqqgWto/+QL24ll8sAAc0n71KY/wvN1W4sAASxTuHv8YWdDnk9u9IdadyPo2DGvDA==}
dependencies:
framesync: 5.2.0
hey-listen: 1.0.8
style-value-types: 4.1.1
tslib: 1.14.1
dev: false
/posix-character-classes@0.1.1:
resolution: {integrity: sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==}
engines: {node: '>=0.10.0'}
@ -8607,13 +8549,6 @@ packages:
resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==}
dev: false
/style-value-types@4.1.1:
resolution: {integrity: sha512-cNLrl6jk+I1T18ZI2KIp/fcqKVuykcNELDrOz7y+TYZR97xmNdN0ewupURvVFnQxVrRJv98TMBq92VMsggq3kw==}
dependencies:
hey-listen: 1.0.8
tslib: 1.14.1
dev: false
/styled-jsx@5.1.1(@babel/core@7.25.2)(react@18.3.1):
resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==}
engines: {node: '>= 12.0.0'}
@ -8858,6 +8793,7 @@ packages:
/tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
dev: true
/tslib@2.7.0:
resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==}

@ -8,7 +8,6 @@ import UploadFileList from '../components/UploadFileList'
import Uploader from '../components/Uploader'
import PasswordField from '../components/PasswordField'
import StartButton from '../components/StartButton'
import StopButton from '../components/StopButton'
import { UploadedFile } from '../types'
import Spinner from '../components/Spinner'
import Wordmark from '../components/Wordmark'
@ -102,9 +101,8 @@ function UploadingState({
</p>
<UploadFileList files={uploadedFiles} />
<WebRTCProvider>
<Uploader files={uploadedFiles} password={password} />
<Uploader files={uploadedFiles} password={password} onStop={onStop} />
</WebRTCProvider>
<StopButton onClick={onStop} />
</PageWrapper>
)
}

@ -0,0 +1,54 @@
import React from 'react'
import { UploaderConnection, UploaderConnectionStatus } from '../types'
import ProgressBar from './ProgressBar'
export function ConnectionListItem({
conn,
}: {
conn: UploaderConnection
}): JSX.Element {
const getStatusColor = (status: UploaderConnectionStatus) => {
switch (status) {
case UploaderConnectionStatus.Uploading:
return 'bg-green-500'
case UploaderConnectionStatus.Paused:
return 'bg-yellow-500'
case UploaderConnectionStatus.Done:
return 'bg-blue-500'
case UploaderConnectionStatus.Closed:
return 'bg-red-500'
default:
return 'bg-gray-500'
}
}
return (
<div className="w-full mt-4">
<div className="flex items-center space-x-2 mb-2">
<span className="text-sm font-medium">
{conn.browserName && conn.browserVersion ? (
<>
{conn.browserName}{' '}
<span className="text-stone-400">v{conn.browserVersion}</span>
</>
) : (
'Downloader'
)}
</span>
<span
className={`px-1.5 py-0.5 text-white rounded-md transition-colors duration-200 font-medium text-[10px] ${getStatusColor(
conn.status,
)}`}
>
{conn.status}
</span>
</div>
<ProgressBar
value={
(conn.completedFiles + conn.currentFileProgress) / conn.totalFiles
}
max={1}
/>
</div>
)
}

@ -0,0 +1,32 @@
import React from 'react'
import useClipboard from '../hooks/useClipboard'
import InputLabel from './InputLabel'
export function CopyableInput({
label,
value,
}: {
label: string
value: string
}): JSX.Element {
const { hasCopied, onCopy } = useClipboard(value)
return (
<div className="flex flex-col w-full">
<InputLabel>{label}</InputLabel>
<div className="flex w-full">
<input
className="flex-grow px-3 py-2 text-xs border border-r-0 rounded-l"
value={value}
readOnly
/>
<button
className="px-4 py-2 text-sm text-stone-700 bg-stone-100 hover:bg-stone-200 rounded-r border-t border-r border-b"
onClick={onCopy}
>
{hasCopied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
)
}

@ -12,6 +12,7 @@ export default function Spinner({
return (
<div className="relative w-[300px] h-[300px]">
<Image
priority
src="/images/pizza.png"
alt="Pizza"
width={300}
@ -20,10 +21,11 @@ export default function Spinner({
/>
<div className="absolute inset-0 flex items-center justify-center">
<Image
priority
src={src}
alt={`Arrow pointing ${direction}`}
width={120}
height={120}
height={173}
/>
</div>
</div>

@ -1,378 +1,27 @@
import React, { useEffect, useState } from 'react'
import React from 'react'
import { UploadedFile } from '../types'
import { useWebRTC } from './WebRTCProvider'
import { useQuery } from '@tanstack/react-query'
import Peer, { DataConnection } from 'peerjs'
import { decodeMessage, Message, MessageType } from '../messages'
import QRCode from 'react-qr-code'
import produce from 'immer'
import * as t from 'io-ts'
import Loading from './Loading'
import ProgressBar from './ProgressBar'
import useClipboard from '../hooks/useClipboard'
import InputLabel from './InputLabel'
import { useUploaderChannelRenewal } from '../hooks/useUploaderChannelRenewal'
import StopButton from './StopButton'
import { useUploaderChannel } from '../hooks/useUploaderChannel'
import { useUploaderConnections } from '../hooks/useUploaderConnections'
import { CopyableInput } from './CopyableInput'
import { ConnectionListItem } from './ConnectionListItem'
enum UploaderConnectionStatus {
Pending = 'PENDING',
Paused = 'PAUSED',
Uploading = 'UPLOADING',
Done = 'DONE',
InvalidPassword = 'INVALID_PASSWORD',
Closed = 'CLOSED',
}
type UploaderConnection = {
status: UploaderConnectionStatus
dataConnection: DataConnection
browserName?: string
browserVersion?: string
osName?: string
osVersion?: string
mobileVendor?: string
mobileModel?: string
uploadingFullPath?: string
uploadingOffset?: number
completedFiles: number
totalFiles: number
currentFileProgress: number
}
// TODO(@kern): Use better values
const MAX_CHUNK_SIZE = 10 * 1024 * 1024 // 10 Mi
const QR_CODE_SIZE = 128
function generateURL(slug: string): string {
const hostPrefix =
window.location.protocol +
'//' +
window.location.hostname +
(['80', '443'].includes(window.location.port)
? ''
: ':' + window.location.port)
return `${hostPrefix}/download/${slug}`
}
function useUploaderChannel(uploaderPeerID: string): {
loading: boolean
error: Error | null
longSlug: string | undefined
shortSlug: string | undefined
longURL: string | undefined
shortURL: string | undefined
} {
const { isLoading, error, data } = useQuery({
queryKey: ['uploaderChannel', uploaderPeerID],
queryFn: async () => {
const response = await fetch('/api/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploaderPeerID }),
})
if (!response.ok) {
throw new Error('Network response was not ok')
}
return response.json()
},
})
const longURL = data?.longSlug ? generateURL(data.longSlug) : undefined
const shortURL = data?.shortSlug ? generateURL(data.shortSlug) : undefined
return {
loading: isLoading,
error: error as Error | null,
longSlug: data?.longSlug,
shortSlug: data?.shortSlug,
longURL,
shortURL,
}
}
function validateOffset(
files: UploadedFile[],
fullPath: string,
offset: number,
): UploadedFile {
const validFile = files.find(
(file) => file.fullPath === fullPath && offset <= file.size,
)
if (!validFile) {
throw new Error('invalid file offset')
}
return validFile
}
function useUploaderConnections(
peer: Peer,
files: UploadedFile[],
password: string,
): Array<UploaderConnection> {
const [connections, setConnections] = useState<Array<UploaderConnection>>([])
useEffect(() => {
peer.on('connection', (conn: DataConnection) => {
let sendChunkTimeout: NodeJS.Timeout | null = null
const newConn = {
status: UploaderConnectionStatus.Pending,
dataConnection: conn,
completedFiles: 0,
totalFiles: files.length,
currentFileProgress: 0,
}
setConnections((conns) => [...conns, newConn])
const updateConnection = (
fn: (draftConn: UploaderConnection) => void,
) => {
setConnections((conns) =>
produce(conns, (draft) => {
const updatedConn = draft.find((c) => c.dataConnection === conn)
if (!updatedConn) {
return
}
fn(updatedConn as UploaderConnection)
}),
)
}
conn.on('data', (data): void => {
try {
const message = decodeMessage(data)
switch (message.type) {
case MessageType.RequestInfo: {
if (message.password !== password) {
const request: t.TypeOf<typeof Message> = {
type: MessageType.Error,
error: 'Invalid password',
}
conn.send(request)
updateConnection((draft) => {
if (draft.status !== UploaderConnectionStatus.Pending) {
return
}
draft.status = UploaderConnectionStatus.InvalidPassword
draft.browserName = message.browserName
draft.browserVersion = message.browserVersion
draft.osName = message.osName
draft.osVersion = message.osVersion
draft.mobileVendor = message.mobileVendor
draft.mobileModel = message.mobileModel
})
return
}
updateConnection((draft) => {
if (draft.status !== UploaderConnectionStatus.Pending) {
return
}
draft.status = UploaderConnectionStatus.Paused
draft.browserName = message.browserName
draft.browserVersion = message.browserVersion
draft.osName = message.osName
draft.osVersion = message.osVersion
draft.mobileVendor = message.mobileVendor
draft.mobileModel = message.mobileModel
})
const fileInfo = files.map((f) => {
return {
fullPath: f.fullPath,
size: f.size,
type: f.type,
}
})
const request: t.TypeOf<typeof Message> = {
type: MessageType.Info,
files: fileInfo,
}
conn.send(request)
break
}
case MessageType.Start: {
const fullPath = message.fullPath
let offset = message.offset
const file = validateOffset(files, fullPath, offset)
updateConnection((draft) => {
if (draft.status !== UploaderConnectionStatus.Paused) {
return
}
draft.status = UploaderConnectionStatus.Uploading
draft.uploadingFullPath = fullPath
draft.uploadingOffset = offset
draft.currentFileProgress = offset / file.size
})
const sendNextChunk = () => {
const end = Math.min(file.size, offset + MAX_CHUNK_SIZE)
const chunkSize = end - offset
const final = chunkSize < MAX_CHUNK_SIZE
const request: t.TypeOf<typeof Message> = {
type: MessageType.Chunk,
fullPath,
offset,
bytes: file.slice(offset, end),
final,
}
conn.send(request)
updateConnection((draft) => {
offset = end
draft.uploadingOffset = end
draft.currentFileProgress = end / file.size
if (final) {
draft.status = UploaderConnectionStatus.Paused
draft.completedFiles += 1
draft.currentFileProgress = 0
} else {
sendChunkTimeout = setTimeout(() => {
sendNextChunk()
}, 0)
}
})
}
sendNextChunk()
break
}
case MessageType.Pause: {
updateConnection((draft) => {
if (draft.status !== UploaderConnectionStatus.Uploading) {
return
}
draft.status = UploaderConnectionStatus.Paused
if (sendChunkTimeout) {
clearTimeout(sendChunkTimeout)
sendChunkTimeout = null
}
})
break
}
case MessageType.Done: {
updateConnection((draft) => {
if (draft.status !== UploaderConnectionStatus.Paused) {
return
}
draft.status = UploaderConnectionStatus.Done
conn.close()
})
break
}
}
} catch (err) {
console.error(err)
}
})
conn.on('close', (): void => {
if (sendChunkTimeout) {
clearTimeout(sendChunkTimeout)
}
updateConnection((draft) => {
if (
[
UploaderConnectionStatus.InvalidPassword,
UploaderConnectionStatus.Done,
].includes(draft.status)
) {
return
}
draft.status = UploaderConnectionStatus.Closed
})
})
})
}, [peer, files, password])
return connections
}
function CopyableInput({ label, value }: { label: string; value: string }) {
const { hasCopied, onCopy } = useClipboard(value)
return (
<div className="flex flex-col w-full">
<InputLabel>{label}</InputLabel>
<div className="flex w-full">
<input
className="flex-grow px-3 py-2 text-xs border border-r-0 rounded-l"
value={value}
readOnly
/>
<button
className="px-4 py-2 text-sm text-stone-700 bg-stone-100 hover:bg-stone-200 rounded-r border-t border-r border-b"
onClick={onCopy}
>
{hasCopied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
)
}
function ConnectionListItem({ conn }: { conn: UploaderConnection }) {
const getStatusColor = (status: UploaderConnectionStatus) => {
switch (status) {
case UploaderConnectionStatus.Uploading:
return 'bg-green-500'
case UploaderConnectionStatus.Paused:
return 'bg-yellow-500'
case UploaderConnectionStatus.Done:
return 'bg-blue-500'
case UploaderConnectionStatus.Closed:
return 'bg-red-500'
default:
return 'bg-gray-500'
}
}
return (
<div className="w-full mt-4">
<div className="flex items-center space-x-2 mb-2">
<span className="text-sm font-medium">
{conn.browserName} {conn.browserVersion}
</span>
<span
className={`px-1.5 py-0.5 text-white rounded-md transition-colors duration-200 font-medium text-[10px] ${getStatusColor(
conn.status,
)}`}
>
{conn.status}
</span>
</div>
<ProgressBar
value={
(conn.completedFiles + conn.currentFileProgress) / conn.totalFiles
}
max={1}
/>
</div>
)
}
export default function Uploader({
files,
password,
renewInterval = 5000,
onStop,
}: {
files: UploadedFile[]
password: string
renewInterval?: number
onStop: () => void
}): JSX.Element {
const peer = useWebRTC()
const { longSlug, shortSlug, longURL, shortURL } = useUploaderChannel(peer.id)
@ -394,9 +43,18 @@ export default function Uploader({
<CopyableInput label="Short URL" value={shortURL ?? ''} />
</div>
</div>
{connections.map((conn, i) => (
<ConnectionListItem key={i} conn={conn} />
))}
<div className="mt-6 pt-4 border-t border-gray-200 w-full">
<div className="flex justify-between items-center mb-2">
<h2 className="text-lg font-semibold text-stone-400">
{connections.length}{' '}
{connections.length === 1 ? 'Downloader' : 'Downloaders'}
</h2>
<StopButton onClick={onStop} />
</div>
{connections.map((conn, i) => (
<ConnectionListItem key={i} conn={conn} />
))}
</div>
</>
)
}

@ -0,0 +1,48 @@
import { useQuery } from '@tanstack/react-query'
function generateURL(slug: string): string {
const hostPrefix =
window.location.protocol +
'//' +
window.location.hostname +
(['80', '443'].includes(window.location.port)
? ''
: ':' + window.location.port)
return `${hostPrefix}/download/${slug}`
}
export function useUploaderChannel(uploaderPeerID: string): {
loading: boolean
error: Error | null
longSlug: string | undefined
shortSlug: string | undefined
longURL: string | undefined
shortURL: string | undefined
} {
const { isLoading, error, data } = useQuery({
queryKey: ['uploaderChannel', uploaderPeerID],
queryFn: async () => {
const response = await fetch('/api/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploaderPeerID }),
})
if (!response.ok) {
throw new Error('Network response was not ok')
}
return response.json()
},
})
const longURL = data?.longSlug ? generateURL(data.longSlug) : undefined
const shortURL = data?.shortSlug ? generateURL(data.shortSlug) : undefined
return {
loading: isLoading,
error: error as Error | null,
longSlug: data?.longSlug,
shortSlug: data?.shortSlug,
longURL,
shortURL,
}
}

@ -0,0 +1,241 @@
import { useState, useEffect } from 'react'
import Peer, { DataConnection } from 'peerjs'
import {
UploadedFile,
UploaderConnection,
UploaderConnectionStatus,
} from '../types'
import { decodeMessage, Message, MessageType } from '../messages'
import { validateOffset } from '../utils/fs'
import * as t from 'io-ts'
// TODO(@kern): Test for better values
const MAX_CHUNK_SIZE = 10 * 1024 * 1024 // 10 Mi
export function useUploaderConnections(
peer: Peer,
files: UploadedFile[],
password: string,
): Array<UploaderConnection> {
const [connections, setConnections] = useState<Array<UploaderConnection>>([])
useEffect(() => {
const listener = (conn: DataConnection) => {
let sendChunkTimeout: NodeJS.Timeout | null = null
const newConn = {
status: UploaderConnectionStatus.Pending,
dataConnection: conn,
completedFiles: 0,
totalFiles: files.length,
currentFileProgress: 0,
}
setConnections((conns) => [newConn, ...conns])
const updateConnection = (
fn: (c: UploaderConnection) => UploaderConnection,
) => {
setConnections((conns) =>
conns.map((c) => (c.dataConnection === conn ? fn(c) : c)),
)
}
conn.on('data', (data): void => {
try {
const message = decodeMessage(data)
console.log('message', message)
switch (message.type) {
case MessageType.RequestInfo: {
if (message.password !== password) {
console.log('invalid password')
const request: t.TypeOf<typeof Message> = {
type: MessageType.Error,
error: 'Invalid password',
}
conn.send(request)
updateConnection((draft) => {
if (draft.status !== UploaderConnectionStatus.Pending) {
return draft
}
return {
...draft,
status: UploaderConnectionStatus.InvalidPassword,
browserName: message.browserName,
browserVersion: message.browserVersion,
osName: message.osName,
osVersion: message.osVersion,
mobileVendor: message.mobileVendor,
mobileModel: message.mobileModel,
}
})
return
}
console.log('valid password')
updateConnection((draft) => {
if (draft.status !== UploaderConnectionStatus.Pending) {
return draft
}
return {
...draft,
status: UploaderConnectionStatus.Paused,
browserName: message.browserName,
browserVersion: message.browserVersion,
osName: message.osName,
osVersion: message.osVersion,
mobileVendor: message.mobileVendor,
mobileModel: message.mobileModel,
}
})
const fileInfo = files.map((f) => {
return {
fullPath: f.fullPath ?? f.name ?? '',
size: f.size,
type: f.type,
}
})
const request: t.TypeOf<typeof Message> = {
type: MessageType.Info,
files: fileInfo,
}
console.log('sending info', request)
conn.send(request)
break
}
case MessageType.Start: {
const fullPath = message.fullPath
let offset = message.offset
const file = validateOffset(files, fullPath, offset)
updateConnection((draft) => {
if (draft.status !== UploaderConnectionStatus.Paused) {
return draft
}
return {
...draft,
status: UploaderConnectionStatus.Uploading,
uploadingFullPath: fullPath,
uploadingOffset: offset,
currentFileProgress: offset / file.size,
}
})
const sendNextChunk = () => {
const end = Math.min(file.size, offset + MAX_CHUNK_SIZE)
const chunkSize = end - offset
const final = chunkSize < MAX_CHUNK_SIZE
const request: t.TypeOf<typeof Message> = {
type: MessageType.Chunk,
fullPath,
offset,
bytes: file.slice(offset, end),
final,
}
conn.send(request)
updateConnection((draft) => {
offset = end
if (final) {
return {
...draft,
status: UploaderConnectionStatus.Paused,
completedFiles: draft.completedFiles + 1,
currentFileProgress: 0,
}
} else {
sendChunkTimeout = setTimeout(() => {
sendNextChunk()
}, 0)
return {
...draft,
uploadingOffset: end,
currentFileProgress: end / file.size,
}
}
})
}
sendNextChunk()
break
}
case MessageType.Pause: {
updateConnection((draft) => {
if (draft.status !== UploaderConnectionStatus.Uploading) {
return draft
}
if (sendChunkTimeout) {
clearTimeout(sendChunkTimeout)
sendChunkTimeout = null
}
return {
...draft,
status: UploaderConnectionStatus.Paused,
}
})
break
}
case MessageType.Done: {
updateConnection((draft) => {
if (draft.status !== UploaderConnectionStatus.Paused) {
return draft
}
conn.close()
return {
...draft,
status: UploaderConnectionStatus.Done,
}
})
break
}
}
} catch (err) {
console.error(err)
}
})
conn.on('close', (): void => {
if (sendChunkTimeout) {
clearTimeout(sendChunkTimeout)
}
updateConnection((draft) => {
if (
[
UploaderConnectionStatus.InvalidPassword,
UploaderConnectionStatus.Done,
].includes(draft.status)
) {
return draft
}
return {
...draft,
status: UploaderConnectionStatus.Closed,
}
})
})
}
peer.on('connection', listener)
return () => {
peer.off('connection')
}
}, [peer, files, password])
return connections
}

@ -1 +1,28 @@
export type UploadedFile = File & { fullPath: string }
import type { DataConnection } from 'peerjs'
export type UploadedFile = File & { fullPath?: string; name?: string }
export enum UploaderConnectionStatus {
Pending = 'PENDING',
Paused = 'PAUSED',
Uploading = 'UPLOADING',
Done = 'DONE',
InvalidPassword = 'INVALID_PASSWORD',
Closed = 'CLOSED',
}
export type UploaderConnection = {
status: UploaderConnectionStatus
dataConnection: DataConnection
browserName?: string
browserVersion?: string
osName?: string
osVersion?: string
mobileVendor?: string
mobileModel?: string
uploadingFullPath?: string
uploadingOffset?: number
completedFiles: number
totalFiles: number
currentFileProgress: number
}

@ -0,0 +1,18 @@
import { UploadedFile } from '../types'
export function validateOffset(
files: UploadedFile[],
fullPath: string,
offset: number,
): UploadedFile {
const validFile = files.find(
(file) =>
(file.fullPath === fullPath || file.name === fullPath) &&
offset <= file.size,
)
debugger
if (!validFile) {
throw new Error('invalid file offset')
}
return validFile
}
Loading…
Cancel
Save