Repository: kern/filepizza
Branch: main
Commit: 3258673e7901
Files: 122
Total size: 295.7 KB
Directory structure:
gitextract_c85d7kb8/
├── .claude/
│ └── settings.local.json
├── .cursorrules
├── .dockerignore
├── .github/
│ └── workflows/
│ ├── main.yml
│ └── tests.yml
├── .gitignore
├── .prettierrc.js
├── CLAUDE.md
├── Dockerfile
├── LICENSE
├── README.md
├── bin/
│ └── peerjs.js
├── docker-compose.production.yml
├── docker-compose.yml
├── docs/
│ └── file-transfer-protocol.md
├── eslint.config.mjs
├── next-env.d.ts
├── next.config.js
├── package.json
├── playwright.config.ts
├── postcss.config.js
├── public/
│ ├── robots.txt
│ ├── stream.html
│ └── sw.js
├── renovate.json
├── scripts/
│ └── pull-and-run.sh
├── src/
│ ├── app/
│ │ ├── api/
│ │ │ ├── create/
│ │ │ │ └── route.ts
│ │ │ ├── destroy/
│ │ │ │ └── route.ts
│ │ │ ├── ice/
│ │ │ │ └── route.ts
│ │ │ └── renew/
│ │ │ └── route.ts
│ │ ├── download/
│ │ │ └── [...slug]/
│ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ ├── not-found.tsx
│ │ ├── page.tsx
│ │ └── reported/
│ │ └── page.tsx
│ ├── channel.ts
│ ├── components/
│ │ ├── AddFilesButton.tsx
│ │ ├── CancelButton.tsx
│ │ ├── ConnectionListItem.tsx
│ │ ├── CopyableInput.tsx
│ │ ├── DownloadButton.tsx
│ │ ├── Downloader.tsx
│ │ ├── DropZone.tsx
│ │ ├── ErrorMessage.tsx
│ │ ├── Footer.tsx
│ │ ├── InputLabel.tsx
│ │ ├── Loading.tsx
│ │ ├── ModeToggle.tsx
│ │ ├── PasswordField.tsx
│ │ ├── ProgressBar.tsx
│ │ ├── QueryClientProvider.tsx
│ │ ├── ReportTermsViolationButton.tsx
│ │ ├── ReturnHome.tsx
│ │ ├── Spinner.tsx
│ │ ├── StartButton.tsx
│ │ ├── StopButton.tsx
│ │ ├── SubtitleText.tsx
│ │ ├── TermsAcceptance.tsx
│ │ ├── ThemeProvider.tsx
│ │ ├── TitleText.tsx
│ │ ├── TypeBadge.tsx
│ │ ├── UnlockButton.tsx
│ │ ├── UploadFileList.tsx
│ │ ├── Uploader.tsx
│ │ ├── WebRTCProvider.tsx
│ │ └── Wordmark.tsx
│ ├── config.ts
│ ├── coturn.ts
│ ├── fs.ts
│ ├── hooks/
│ │ ├── useClipboard.ts
│ │ ├── useDownloader.ts
│ │ ├── useRotatingSpinner.ts
│ │ ├── useUploaderChannel.ts
│ │ └── useUploaderConnections.ts
│ ├── log.ts
│ ├── messages.ts
│ ├── redisClient.ts
│ ├── routes.ts
│ ├── slugs.ts
│ ├── styles.css
│ ├── toppings.ts
│ ├── types.ts
│ ├── utils/
│ │ ├── download.ts
│ │ └── pluralize.ts
│ └── zip-stream.ts
├── tests/
│ ├── e2e/
│ │ ├── add-files.test.ts
│ │ ├── basic.test.ts
│ │ ├── file-transfer.test.ts
│ │ └── helpers.ts
│ └── unit/
│ ├── CancelButton.test.tsx
│ ├── ConnectionListItem.test.tsx
│ ├── CopyableInput.test.tsx
│ ├── DownloadButton.test.tsx
│ ├── Downloader.subcomponents.test.tsx
│ ├── DropZone.test.tsx
│ ├── ErrorMessage.test.tsx
│ ├── Footer.test.tsx
│ ├── InputLabel.test.tsx
│ ├── Loading.test.tsx
│ ├── ModeToggle.test.tsx
│ ├── PasswordField.test.tsx
│ ├── ProgressBar.test.tsx
│ ├── QueryClientProvider.test.tsx
│ ├── ReportTermsViolationButton.test.tsx
│ ├── ReturnHome.test.tsx
│ ├── Spinner.test.tsx
│ ├── StartButton.test.tsx
│ ├── StopButton.test.tsx
│ ├── TermsAcceptance.test.tsx
│ ├── ThemeProvider.test.tsx
│ ├── TitleText.test.tsx
│ ├── TypeBadge.test.tsx
│ ├── UnlockButton.test.tsx
│ ├── UploadFileList.test.tsx
│ ├── Uploader.test.tsx
│ ├── WebRTCProvider.test.tsx
│ ├── Wordmark.test.tsx
│ ├── isFinalChunk.test.ts
│ └── useRotatingSpinner.test.ts
├── tsconfig.json
├── vitest.config.ts
└── vitest.setup.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .claude/settings.local.json
================================================
{
"permissions": {
"allow": [
"Bash(pnpm build:*)",
"Bash(pnpm test:*)",
"Bash(npm test:*)"
],
"deny": []
}
}
================================================
FILE: .cursorrules
================================================
- Use TypeScript.
- Use function syntax for defining React components. Define the prop types inline.
- If a value is exported, it should be exported on the same line as its definition.
- Always define the return type of a function or component.
- Use Tailwind CSS for styling.
- Don't use trailing semicolons.
================================================
FILE: .dockerignore
================================================
.DS_Store
.next
node_modules
dist
.env
================================================
FILE: .github/workflows/main.yml
================================================
name: main
on:
push:
branches:
- main
jobs:
build:
name: Docker build, tag, and push
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Docker build, tag, and push
uses: pangzineng/Github-Action-One-Click-Docker@master
env:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
================================================
FILE: .github/workflows/tests.yml
================================================
name: tests
on: [push]
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install
- run: pnpm exec playwright install --with-deps
- run: pnpm lint:check
- run: pnpm format:check
- run: pnpm type:check
- run: pnpm test
- run: pnpm build
- run: pnpm test:e2e
================================================
FILE: .gitignore
================================================
.DS_Store
.next
node_modules
dist
tsconfig.tsbuildinfo
.env
================================================
FILE: .prettierrc.js
================================================
'use strict';
module.exports = {
semi: false,
trailingComma: 'all',
singleQuote: true,
printWidth: 80,
tabWidth: 2,
};
================================================
FILE: CLAUDE.md
================================================
# FilePizza Development Guide
A peer-to-peer file transfer application built with modern web technologies.
## Prerequisites
- [Node.js](https://nodejs.org/) (v18+)
- [pnpm](https://pnpm.io/) (preferred package manager)
## Quick Start
```bash
git clone https://github.com/kern/filepizza.git
cd filepizza
pnpm install
pnpm dev
```
## Available Commands
### Development
- `pnpm dev` - Start development server
- `pnpm dev:full` - Start with Redis and COTURN for full WebRTC testing
### Building & Testing
- `pnpm build` - Build for production
- `pnpm test` - Run unit tests with Vitest
- `pnpm test:watch` - Run tests in watch mode
- `pnpm test:e2e` - Run E2E tests with Playwright
### Code Quality
- `pnpm lint:check` - Check ESLint rules
- `pnpm lint:fix` - Fix ESLint issues
- `pnpm format` - Format code with Prettier
- `pnpm format:check` - Check code formatting
- `pnpm type:check` - TypeScript type checking
### Docker
- `pnpm docker:build` - Build Docker image
- `pnpm docker:up` - Start containers
- `pnpm docker:down` - Stop containers
### CI Pipeline
- `pnpm ci` - Run full CI pipeline (lint, format, type-check, test, build, e2e, docker)
## Tech Stack
- **Framework**: Next.js 15 with App Router
- **UI**: React 19 + Tailwind CSS v4
- **Language**: TypeScript
- **Testing**: Vitest (unit) + Playwright (E2E)
- **WebRTC**: PeerJS
- **State Management**: TanStack Query
- **Themes**: next-themes with View Transitions
- **Storage**: Redis (optional)
## Project Structure
```
src/
├── app/ # Next.js App Router pages
├── components/ # React components
├── hooks/ # Custom React hooks
├── utils/ # Utility functions
└── types.ts # TypeScript definitions
```
## Development Tips
### Using pnpm
This project uses pnpm as the package manager. Benefits include:
- Faster installs and smaller disk usage
- Strict dependency resolution
- Built-in workspace support
Always use `pnpm` instead of `npm` or `yarn`:
```bash
pnpm install package-name
pnpm remove package-name
pnpm update
```
### Code Style
- ESLint + TypeScript ESLint for linting
- Prettier for formatting
- Husky + lint-staged for pre-commit hooks
- Prefer TypeScript over JavaScript
- Use kebab-case for files, PascalCase for components
### Testing Strategy
- Unit tests for components and utilities (`tests/unit/`)
- E2E tests for critical user flows (`tests/e2e/`)
- Test files follow `*.test.ts[x]` naming convention
### WebRTC Development
For full WebRTC testing with TURN/STUN:
```bash
pnpm dev:full
```
This starts Redis and COTURN containers for testing peer connections behind NAT.
## Key Dependencies
- `next` - React framework
- `tailwindcss` - CSS framework
- `@tanstack/react-query` - Server state management
- `peerjs` - WebRTC abstraction
- `next-themes` - Theme switching
- `zod` - Schema validation
- `vitest` - Testing framework
- `playwright` - E2E testing
Run `pnpm ci` before submitting PRs to ensure all checks pass.
================================================
FILE: Dockerfile
================================================
# Stage 1: Dependencies
FROM node:lts-alpine AS deps
RUN apk add --no-cache pnpm
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
# Need all dependencies for build
RUN pnpm install --frozen-lockfile
# Stage 2: Builder
FROM node:lts-alpine AS builder
RUN apk add --no-cache pnpm
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Builds standalone output
RUN pnpm build
# Stage 3: Runner
FROM node:lts-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
ENV PORT 3000
# Only copy standalone output - no need for node_modules
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER node
EXPOSE 3000
# Uses standalone server
CMD ["node", "server.js"]
================================================
FILE: LICENSE
================================================
Copyright (c) 2015, Alex Kern
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
## SIL OPEN FONT LICENSE for fonts in static/fonts
Version 1.1 - 26 February 2007
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting — in part or in whole — any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
================================================
FILE: README.md
================================================
Peer-to-peer file transfers in your browser
*Cooked up by [Alex Kern](https://kern.io) & [Neeraj Baid](https://github.com/neerajbaid) while eating Sliver @ UC Berkeley.*
Using [WebRTC](http://www.webrtc.org), FilePizza eliminates the initial upload step required by other web-based file sharing services. Because data is never stored in an intermediary server, the transfer is fast, private, and secure.
A hosted instance of FilePizza is available at [file.pizza](https://file.pizza).
## What's new with FilePizza v2
* A new UI with dark mode support, now built on modern browser technologies.
* Works on most mobile browsers, including Mobile Safari.
* Transfers are now directly from the uploader to the downloader's browser (WebRTC without WebTorrent) with faster handshakes.
* Uploaders can monitor the progress of the transfer and stop it if they want.
* Better security and safety measures with password protection and reporting.
* Support for uploading multiple files at once, which downloaders receive as a zip file.
* Streaming downloads with a Service Worker.
* Out-of-process storage of server state using Redis.
## Development
```
$ git clone https://github.com/kern/filepizza.git
$ pnpm install
$ pnpm dev
$ pnpm build
$ pnpm start
```
## Running with Docker
```
$ pnpm docker:build
$ pnpm docker:up
$ pnpm docker:down
```
## Stack
* Next.js
* Tailwind
* TypeScript
* React
* PeerJS for WebRTC
* View Transitions
* Redis (optional)
## Configuration
The server can be customized with the following environment variables:
- `REDIS_URL` – Connection string for a Redis instance used to store channel metadata. If not set, FilePizza falls back to in-memory storage.
- `COTURN_ENABLED` – When set to `true`, enables TURN support for connecting peers behind NAT.
- `TURN_HOST` – Hostname or IP address of the TURN server. Defaults to `127.0.0.1`.
- `TURN_REALM` – Realm used when generating TURN credentials. Defaults to `file.pizza`.
- `STUN_SERVER` – STUN server URL to use when `COTURN_ENABLED` is disabled. Defaults to `stun:stun.l.google.com:19302`.
- `PEERJS_HOST` – Hostname or IP address to the self-hosted PeerJS server. Defaults to `0.peerjs.com`.
- `PEERJS_PATH` – Path to self-hosted PeerJS server. Defaults to `/`.
## FAQ
**How are my files sent?** Your files are sent directly from your browser to the downloader's browser. They never pass through our servers. FilePizza uses WebRTC to send files. This requires that the uploader leave their browser window open until the transfer is complete.
**Can multiple people download my file at once?** Yes! Just send them your short or long URL.
**How big can my files be?** As big as your browser can handle.
**What happens when I close my browser?** The URLs for your files will no longer work. If a downloader has completed the transfer, that downloader will continue to seed to incomplete downloaders, but no new downloads may be initiated.
**Are my files encrypted?** Yes, all WebRTC communications are automatically encrypted using public-key cryptography because of DTLS. You can add an optional password to your upload for an extra layer of security.
## License & Acknowledgements
FilePizza is released under the [BSD 3-Clause license](https://github.com/kern/filepizza/blob/main/LICENSE). A huge thanks to [iblowyourdesign](https://dribbble.com/iblowyourdesign) for the pizza illustration.
================================================
FILE: bin/peerjs.js
================================================
#!/usr/bin/env node
const express = require('express')
const { ExpressPeerServer } = require('peer')
const app = express();
const server = app.listen(9000);
const peerServer = ExpressPeerServer(server, {
path: '/filepizza'
})
app.use('/peerjs', peerServer)
================================================
FILE: docker-compose.production.yml
================================================
services:
redis:
image: redis:latest
ports:
- 127.0.0.1:6379:6379
networks:
- filepizza
volumes:
- redis_data:/data
coturn:
image: coturn/coturn
ports:
- 3478:3478
- 3478:3478/udp
- 5349:5349
- 5349:5349/udp
- 60000-60128:60000-60128/udp
environment:
- DETECT_EXTERNAL_IP=yes
- DETECT_RELAY_IP=yes
command: -n --log-file=stdout --redis-userdb="ip=redis connect_timeout=30" --min-port=60000 --max-port=60128
networks:
- filepizza
filepizza:
build: .
image: kern/filepizza:latest
ports:
- 0.0.0.0:80:80
environment:
- PORT=80
- REDIS_URL=redis://redis:6379
- COTURN_ENABLED=true
networks:
- filepizza
depends_on:
- redis
env_file:
- .env
networks:
filepizza:
driver: bridge
volumes:
redis_data:
================================================
FILE: docker-compose.yml
================================================
services:
redis:
image: redis:latest
ports:
- 6379:6379
networks:
- filepizza
volumes:
- redis_data:/data
coturn:
image: coturn/coturn
ports:
- 3478:3478
- 3478:3478/udp
- 5349:5349
- 5349:5349/udp
# Relay Ports
# - 49152-65535:49152-65535/udp
environment:
- DETECT_EXTERNAL_IP=yes
- DETECT_RELAY_IP=yes
command: -n --log-file=stdout --redis-userdb="ip=redis connect_timeout=30"
networks:
- filepizza
filepizza:
build: .
image: kern/filepizza:latest
ports:
- 8080:8080
environment:
- PORT=8080
- REDIS_URL=redis://redis:6379
networks:
- filepizza
depends_on:
- redis
networks:
filepizza:
driver: bridge
volumes:
redis_data:
================================================
FILE: docs/file-transfer-protocol.md
================================================
# FilePizza File Transfer Protocol
This document explains the message-based protocol that FilePizza uses to
transfer files directly between browsers over a WebRTC data channel. It
covers the complete conversation required to build either an uploader or a
downloader and includes examples for common scenarios.
## Architecture Overview
```mermaid
flowchart LR
Uploader -- WebRTC / PeerJS --> Downloader
Uploader -- REST --> Server[(FilePizza Server)]
Downloader -- REST --> Server
Server -- signalling / slug --> Uploader
Server -- signalling / slug --> Downloader
```
1. The uploader creates a channel with the server and receives a slug that
encodes its PeerJS identifier.
2. The downloader resolves the slug via the server to obtain the uploader's
PeerJS identifier.
3. All subsequent messages travel directly between peers over a reliable
WebRTC data channel.
## Message Types
Every message is a JSON object with a `type` field that matches one of the
values in the table below. Fields marked with `?` are optional.
```mermaid
classDiagram
class RequestInfo {
+"RequestInfo" type
+string browserName
+string browserVersion
+string osName
+string osVersion
+string mobileVendor
+string mobileModel
}
class Info {
+"Info" type
+FileInfo[] files
}
class FileInfo {
+string fileName
+number size
+string type
}
class Start {
+"Start" type
+string fileName
+number offset
}
class Chunk {
+"Chunk" type
+string fileName
+number offset
+ArrayBuffer bytes
+boolean final
}
class ChunkAck {
+"ChunkAck" type
+string fileName
+number offset
+number bytesReceived
}
class Pause {
+"Pause" type
}
class Done {
+"Done" type
}
class Error {
+"Error" type
+string error
}
class PasswordRequired {
+"PasswordRequired" type
+string errorMessage?
}
class UsePassword {
+"UsePassword" type
+string password
}
class Report {
+"Report" type
}
```
Chunks are sent in pieces of at most 256 KiB (`MAX_CHUNK_SIZE`). The `final` flag in a `Chunk` message marks the last piece of a file.
## Normal Transfer Sequence
The following diagram shows the exchange for downloading multiple files
without a password.
```mermaid
sequenceDiagram
participant D as Downloader
participant U as Uploader
D->>U: RequestInfo
U-->>D: Info(files)
loop For each file
D->>U: Start(fileName, offset=0)
loop For each chunk
U-->>D: Chunk(offset, bytes, final=false)
D->>U: ChunkAck(offset, bytesReceived)
end
U-->>D: Chunk(offset, bytes, final=true)
D->>U: ChunkAck(offset, bytesReceived)
end
D->>U: Done
U-->>D: close connection
```
## Password‑Protected Transfers
If the uploader specified a password when creating the channel, the
conversation includes an authentication step.
```mermaid
sequenceDiagram
participant D as Downloader
participant U as Uploader
D->>U: RequestInfo
U-->>D: PasswordRequired(errorMessage?)
D->>U: UsePassword(password)
U-->>D: Info(files) or PasswordRequired("Invalid password")
Note over D,U: Continue with normal transfer sequence on success
```
## Pause and Resume
A downloader may pause an in‑progress transfer. To resume, it reconnects and
requests the remainder of the file starting at the last acknowledged offset.
```mermaid
sequenceDiagram
participant D as Downloader
participant U as Uploader
D->>U: Start(fileName, offset=0)
U-->>D: Chunk(...)
D->>U: ChunkAck(...)
D->>U: Pause
Note over D,U: Connection closed or kept idle
D->>U: Start(fileName, offset=previouslyAcked)
Note over D,U: Transfer resumes from offset
```
## Reporting
A special PeerJS connection with metadata `{ type: "report" }` causes the
uploader to broadcast a `Report` message to all connected downloaders and to
redirect its own UI to a reported page. Downloaders receiving this message
should abort the transfer.
```mermaid
sequenceDiagram
participant Reporter
participant U as Uploader
participant D as Downloader
Reporter->>U: Peer connection(type="report")
U-->>D: Report
U-->>Reporter: redirect to /reported
```
## Example Conversations
### Single file without password
```
RequestInfo
Info [{ fileName: "photo.jpg", size: 1048576, type: "image/jpeg" }]
Start { fileName: "photo.jpg", offset: 0 }
Chunk { offset: 0, bytes: <256 KB>, final: false }
ChunkAck { offset: 0, bytesReceived: 262144 }
...
Chunk { offset: 1048576, bytes: <0>, final: true }
ChunkAck { offset: 1048576, bytesReceived: 0 }
Done
```
### Password‑protected download
```
RequestInfo
PasswordRequired
UsePassword { password: "secret" }
Info [...]
...
```
### Resuming after interruption
```
RequestInfo
Info [...]
Start { fileName: "video.mp4", offset: 0 }
Chunk/ChunkAck exchanges...
Start { fileName: "video.mp4", offset: 1048576 }
Chunk/ChunkAck exchanges...
Done
```
---
With these message definitions and sequences you can implement a compatible
uploader or downloader for FilePizza or adapt the protocol for other
applications.
================================================
FILE: eslint.config.mjs
================================================
// @ts-check
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config({
extends: [
eslint.configs.recommended,
tseslint.configs.recommended,
],
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-use-before-define': [
'error',
{ variables: false },
],
'@typescript-eslint/promise-function-async': 'off',
'@typescript-eslint/require-await': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'import/no-unused-modules': 'off',
'import/group-exports': 'off',
'import/no-extraneous-dependencies': 'off',
'new-cap': 'off',
'no-inline-comments': 'off',
'no-shadow': 'warn',
'no-use-before-define': 'off',
},
files: ['src/**/*.ts[x]'],
ignores: ['legacy', 'node_modules', '.next'],
});
================================================
FILE: next-env.d.ts
================================================
///
///
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
================================================
FILE: next.config.js
================================================
/** @type {import('next').NextConfig} */
const nextConfig = {
// Disable strict mode to avoid calling useEffect twice in development.
// The uploader and downloader are both using useEffect to listen for peerjs events
// which causes the connection to be created twice.
reactStrictMode: false,
output: 'standalone'
}
module.exports = nextConfig
================================================
FILE: package.json
================================================
{
"name": "filepizza",
"version": "2.0.0",
"description": "Free peer-to-peer file transfers in your browser.",
"author": "Alex Kern (http://kern.io)",
"license": "BSD-3-Clause",
"homepage": "https://github.com/kern/filepizza",
"scripts": {
"dev": "next",
"dev:full": "docker compose up redis coturn -d && COTURN_ENABLED=true REDIS_URL=redis://localhost:6379 next",
"build": "next build && cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/",
"start": "next start",
"start:peerjs": "./bin/peerjs.js",
"lint:check": "eslint 'src/**/*.ts[x]'",
"lint:fix": "eslint 'src/**/*.ts[x]' --fix",
"docker:build": "docker compose build",
"docker:up": "docker compose up -d",
"docker:down": "docker compose down",
"docker:logs": "docker compose logs -f",
"docker:ps": "docker compose ps",
"docker:restart": "docker compose restart",
"docker:clean": "docker compose down -v --rmi all",
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
"type:check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"ci": "pnpm lint:check && pnpm format:check && pnpm type:check && pnpm test && pnpm build && pnpm test:e2e && pnpm docker:build"
},
"repository": {
"type": "git",
"url": "git@github.com:kern/filepizza.git"
},
"bugs": {
"url": "https://github.com/kern/filepizza/issues"
},
"dependencies": {
"@tailwindcss/postcss": "^4.1.11",
"@tanstack/react-query": "^5.55.2",
"autoprefixer": "^10.4.20",
"debug": "^4.3.6",
"express": "^5.0.0",
"ioredis": "^5.4.2",
"next": "~15.5.11",
"next-themes": "^0.4.4",
"next-view-transitions": "^0.3.4",
"nodemon": "^3.0.0",
"peer": "^1.0.0",
"peerjs": "^1.5.4",
"postcss": "^8.4.44",
"react": "~19.2.3",
"react-device-detect": "^2.0.0",
"react-dom": "~19.2.3",
"react-qr-code": "^2.0.15",
"streamsaver": "^2.0.6",
"tailwindcss": "^4.1.11",
"web-streams-polyfill": "^4.0.0",
"webrtcsupport": "^2.2.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@eslint/js": "^9.30.0",
"@playwright/test": "^1.53.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/debug": "^4.1.12",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@typescript-eslint/eslint-plugin": "^8.18.2",
"@typescript-eslint/parser": "^8.18.2",
"@vitejs/plugin-react": "^4.6.0",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.17.0",
"eslint-config-next": "~15.5.11",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-react": "^7.37.3",
"husky": "^9.0.0",
"jsdom": "^26.1.0",
"lint-staged": "^16.0.0",
"playwright": "^1.53.2",
"prettier": "^3.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.18.2",
"vitest": "^3.2.4"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write",
"git add"
]
}
}
================================================
FILE: playwright.config.ts
================================================
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
workers: 1, // Run tests serially to avoid WebRTC port conflicts
webServer: {
command: 'node .next/standalone/server.js',
url: 'http://localhost:3000',
timeout: 120 * 1000,
reuseExistingServer: true,
},
use: {
baseURL: 'http://localhost:3000',
},
})
================================================
FILE: postcss.config.js
================================================
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}
================================================
FILE: public/robots.txt
================================================
User-agent: *
Disallow:
================================================
FILE: public/stream.html
================================================
================================================
FILE: public/sw.js
================================================
// https://github.com/jimmywarting/StreamSaver.js/blob/master/sw.js
/* global self ReadableStream Response */
self.addEventListener('install', () => {
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim())
})
const map = new Map()
// This should be called once per download
// Each event has a dataChannel that the data will be piped through
self.onmessage = (event) => {
// We send a heartbeat every x secound to keep the
// service worker alive if a transferable stream is not sent
if (event.data === 'ping') {
return
}
const data = event.data
const downloadUrl =
data.url ||
self.registration.scope +
Math.random() +
'/' +
(typeof data === 'string' ? data : data.filename)
const port = event.ports[0]
const metadata = new Array(3) // [stream, data, port]
metadata[1] = data
metadata[2] = port
// Note to self:
// old streamsaver v1.2.0 might still use `readableStream`...
// but v2.0.0 will always transfer the stream throught MessageChannel #94
if (event.data.readableStream) {
metadata[0] = event.data.readableStream
} else if (event.data.transferringReadable) {
port.onmessage = (evt) => {
port.onmessage = null
metadata[0] = evt.data.readableStream
}
} else {
metadata[0] = createStream(port)
}
map.set(downloadUrl, metadata)
port.postMessage({ download: downloadUrl })
}
function createStream(port) {
// ReadableStream is only supported by chrome 52
return new ReadableStream({
start(controller) {
// When we receive data on the messageChannel, we write
port.onmessage = ({ data }) => {
if (data === 'end') {
return controller.close()
}
if (data === 'abort') {
controller.error('Aborted the download')
return
}
controller.enqueue(data)
}
},
cancel() {
console.log('user aborted')
},
})
}
self.onfetch = (event) => {
const url = event.request.url
// this only works for Firefox
if (url.endsWith('/ping')) {
return event.respondWith(new Response('pong'))
}
const hijacke = map.get(url)
if (!hijacke) return null
const [stream, data, port] = hijacke
map.delete(url)
// Not comfortable letting any user control all headers
// so we only copy over the length & disposition
const responseHeaders = new Headers({
'Content-Type': 'application/octet-stream; charset=utf-8',
// To be on the safe side, The link can be opened in a iframe.
// but octet-stream should stop it.
'Content-Security-Policy': "default-src 'none'",
'X-Content-Security-Policy': "default-src 'none'",
'X-WebKit-CSP': "default-src 'none'",
'X-XSS-Protection': '1; mode=block',
})
let headers = new Headers(data.headers || {})
if (headers.has('Content-Length')) {
responseHeaders.set('Content-Length', headers.get('Content-Length'))
}
if (headers.has('Content-Disposition')) {
responseHeaders.set(
'Content-Disposition',
headers.get('Content-Disposition'),
)
}
// data, data.filename and size should not be used anymore
if (data.size) {
console.warn('Depricated')
responseHeaders.set('Content-Length', data.size)
}
let fileName = typeof data === 'string' ? data : data.filename
if (fileName) {
console.warn('Depricated')
// Make filename RFC5987 compatible
fileName = encodeURIComponent(fileName)
.replace(/['()]/g, escape)
.replace(/\*/g, '%2A')
responseHeaders.set(
'Content-Disposition',
"attachment; filename*=UTF-8''" + fileName,
)
}
event.respondWith(new Response(stream, { headers: responseHeaders }))
port.postMessage({ debug: 'Download started' })
}
================================================
FILE: renovate.json
================================================
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}
================================================
FILE: scripts/pull-and-run.sh
================================================
#!/bin/bash
set -euo pipefail
git pull origin main
sudo docker pull kern/filepizza:latest
sudo docker compose -f docker-compose.production.yml up -d
sudo docker compose logs -f
================================================
FILE: src/app/api/create/route.ts
================================================
import { NextResponse } from 'next/server'
import { getOrCreateChannelRepo } from '../../../channel'
export async function POST(request: Request): Promise {
const { uploaderPeerID } = await request.json()
if (!uploaderPeerID) {
return NextResponse.json(
{ error: 'Uploader peer ID is required' },
{ status: 400 },
)
}
const channel = await getOrCreateChannelRepo().createChannel(uploaderPeerID)
return NextResponse.json(channel)
}
================================================
FILE: src/app/api/destroy/route.ts
================================================
import { NextRequest, NextResponse } from 'next/server'
import { getOrCreateChannelRepo } from '../../../channel'
export async function POST(request: NextRequest): Promise {
const { slug } = await request.json()
if (!slug) {
return NextResponse.json({ error: 'Slug is required' }, { status: 400 })
}
// Anyone can destroy a channel if they know the slug. This enables a terms violation reporter to destroy the channel after they report it.
try {
await getOrCreateChannelRepo().destroyChannel(slug)
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
console.error(error)
return NextResponse.json(
{ error: 'Failed to destroy channel' },
{ status: 500 },
)
}
}
================================================
FILE: src/app/api/ice/route.ts
================================================
import { NextResponse } from 'next/server'
import crypto from 'crypto'
import { setTurnCredentials } from '../../../coturn'
const turnHost = process.env.TURN_HOST || '127.0.0.1'
const stunServer = process.env.STUN_SERVER || 'stun:stun.l.google.com:19302'
const peerjsHost = process.env.PEERJS_HOST || '0.peerjs.com'
const peerjsPath = process.env.PEERJS_PATH || '/'
export async function POST(): Promise {
if (!process.env.COTURN_ENABLED) {
return NextResponse.json({
host: peerjsHost,
path: peerjsPath,
iceServers: [{ urls: stunServer }],
})
}
// Generate ephemeral credentials
const username = crypto.randomBytes(8).toString('hex')
const password = crypto.randomBytes(8).toString('hex')
const ttl = 86400 // 24 hours
// Store credentials in Redis
await setTurnCredentials(username, password, ttl)
return NextResponse.json({
host: peerjsHost,
path: peerjsPath,
iceServers: [
{ urls: stunServer },
{
urls: [`turn:${turnHost}:3478`, `turns:${turnHost}:5349`],
username,
credential: password,
},
],
})
}
================================================
FILE: src/app/api/renew/route.ts
================================================
import { NextRequest, NextResponse } from 'next/server'
import { getOrCreateChannelRepo } from '../../../channel'
export async function POST(request: NextRequest): Promise {
const { slug, secret } = await request.json()
if (!slug) {
return NextResponse.json({ error: 'Slug is required' }, { status: 400 })
}
if (!secret) {
return NextResponse.json({ error: 'Secret is required' }, { status: 400 })
}
const success = await getOrCreateChannelRepo().renewChannel(slug, secret)
return NextResponse.json({ success })
}
================================================
FILE: src/app/download/[...slug]/page.tsx
================================================
import { JSX } from 'react'
import { notFound } from 'next/navigation'
import { getOrCreateChannelRepo } from '../../../channel'
import Spinner from '../../../components/Spinner'
import Wordmark from '../../../components/Wordmark'
import Downloader from '../../../components/Downloader'
import WebRTCPeerProvider from '../../../components/WebRTCProvider'
import ReportTermsViolationButton from '../../../components/ReportTermsViolationButton'
const normalizeSlug = (rawSlug: string | string[]): string => {
if (typeof rawSlug === 'string') {
return rawSlug
} else {
return rawSlug.join('/')
}
}
export default async function DownloadPage({
params,
}: {
params: Promise<{ slug: string[] }>
}): Promise {
const { slug: slugRaw } = await params
const slug = normalizeSlug(slugRaw)
const channel = await getOrCreateChannelRepo().fetchChannel(slug)
if (!channel) {
notFound()
}
return (
)
}
================================================
FILE: src/app/layout.tsx
================================================
import React from 'react'
import Footer from '../components/Footer'
import '../styles.css'
import { ThemeProvider } from '../components/ThemeProvider'
import { ModeToggle } from '../components/ModeToggle'
import FilePizzaQueryClientProvider from '../components/QueryClientProvider'
import { Viewport } from 'next'
import { ViewTransitions } from 'next-view-transitions'
export const metadata = {
title: 'FilePizza • Your files, delivered.',
description: 'Peer-to-peer file transfers in your web browser.',
charSet: 'utf-8',
openGraph: {
url: 'https://file.pizza',
title: 'FilePizza • Your files, delivered.',
description: 'Peer-to-peer file transfers in your web browser.',
images: [{ url: 'https://file.pizza/images/fb.png' }],
},
}
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}): React.ReactElement {
return (
{children}
)
}
================================================
FILE: src/app/not-found.tsx
================================================
import { JSX } from 'react'
import Spinner from '../components/Spinner'
import Wordmark from '../components/Wordmark'
import ReturnHome from '../components/ReturnHome'
import TitleText from '../components/TitleText'
export const metadata = {
title: 'FilePizza - 404: Slice Not Found',
description: 'Oops! This slice of FilePizza seems to be missing.',
}
export default async function NotFound(): Promise {
return (
404: Looks like this slice of FilePizza got eaten!
)
}
================================================
FILE: src/app/page.tsx
================================================
'use client'
import React, { JSX, useCallback, useState } from 'react'
import WebRTCPeerProvider from '../components/WebRTCProvider'
import DropZone from '../components/DropZone'
import UploadFileList from '../components/UploadFileList'
import Uploader from '../components/Uploader'
import PasswordField from '../components/PasswordField'
import StartButton from '../components/StartButton'
import { UploadedFile } from '../types'
import Spinner from '../components/Spinner'
import Wordmark from '../components/Wordmark'
import CancelButton from '../components/CancelButton'
import { useMemo } from 'react'
import { getFileName } from '../fs'
import TitleText from '../components/TitleText'
import SubtitleText from '../components/SubtitleText'
import { pluralize } from '../utils/pluralize'
import TermsAcceptance from '../components/TermsAcceptance'
import AddFilesButton from '../components/AddFilesButton'
function PageWrapper({ children }: { children: React.ReactNode }): JSX.Element {
return (
Just like a pizza with questionable toppings, we've had to put this
delivery on hold for potential violations of our terms of service. Our
delivery quality team is looking into it to ensure we maintain our
high standards.
)
}
================================================
FILE: src/components/DownloadButton.tsx
================================================
import React, { JSX } from 'react'
export default function DownloadButton({
onClick,
}: {
onClick?: React.MouseEventHandler
}): JSX.Element {
return (
)
}
================================================
FILE: src/components/Downloader.tsx
================================================
'use client'
import React, { JSX, useState, useCallback, useEffect } from 'react'
import { useDownloader } from '../hooks/useDownloader'
import PasswordField from './PasswordField'
import UnlockButton from './UnlockButton'
import Loading from './Loading'
import UploadFileList from './UploadFileList'
import DownloadButton from './DownloadButton'
import StopButton from './StopButton'
import ProgressBar from './ProgressBar'
import TitleText from './TitleText'
import ReturnHome from './ReturnHome'
import { pluralize } from '../utils/pluralize'
import { ErrorMessage } from './ErrorMessage'
interface FileInfo {
fileName: string
size: number
type: string
}
export function ConnectingToUploader({
showTroubleshootingAfter = 3000,
}: {
showTroubleshootingAfter?: number
}): JSX.Element {
const [showTroubleshooting, setShowTroubleshooting] = useState(false)
useEffect(() => {
const timer = setTimeout(() => {
setShowTroubleshooting(true)
}, showTroubleshootingAfter)
return () => clearTimeout(timer)
}, [showTroubleshootingAfter])
if (!showTroubleshooting) {
return
}
return (
<>
Having trouble connecting?
FilePizza uses direct peer-to-peer connections, but sometimes the
connection can get stuck. Here are some possible reasons this can
happen:
🚪
The uploader may have closed their browser, lost connectivity,
or stopped the upload. FilePizza requires the uploader to stay
online continuously because files are transferred directly
between browsers.
🔒
Your network might have strict firewalls or NAT settings, such
as having UPnP disabled
🌐
Some corporate or school networks block peer-to-peer connections
>
)
}
export function DownloadComplete({
filesInfo,
bytesDownloaded,
totalSize,
}: {
filesInfo: FileInfo[]
bytesDownloaded: number
totalSize: number
}): JSX.Element {
return (
<>
You downloaded {pluralize(filesInfo.length, 'file', 'files')}.
>
)
}
export function DownloadInProgress({
filesInfo,
bytesDownloaded,
totalSize,
onStop,
}: {
filesInfo: FileInfo[]
bytesDownloaded: number
totalSize: number
onStop: () => void
}): JSX.Element {
return (
<>
You are downloading {pluralize(filesInfo.length, 'file', 'files')}.
>
)
}
export function ReadyToDownload({
filesInfo,
onStart,
}: {
filesInfo: FileInfo[]
onStart: () => void
}): JSX.Element {
return (
<>
You are about to start downloading{' '}
{pluralize(filesInfo.length, 'file', 'files')}.