A real-time collaborative notepad with end-to-end encryption, built on Cloudflare's edge network using Yjs CRDT for conflict-free synchronization.
Live: https://yp.pe
Table of Contents
- Features
- Tech Stack
- Architecture
- Quick Start
- Development
- Deployment
- Usage
- API Reference
- Key Features Explained
- Contributing
- License
Features
Real-Time Collaboration
- Multi-User Editing: Multiple users can edit simultaneously with Yjs CRDT
- Conflict-Free Sync: CRDTs guarantee eventual consistency without conflicts
- Remote Cursors: See other users' cursor positions via Yjs Awareness protocol
- User Presence: Live count of connected collaborators (editors vs viewers)
- Editor Limits: Maximum 10 concurrent editors per note with real-time status
Security & Privacy
- True End-to-End Encryption: AES-GCM 256-bit client-side encryption for password-protected notes
- Password Never Leaves Browser: Passwords are used locally for encryption/decryption only
- Password Protection: PBKDF2 key derivation (100,000 iterations)
- Zero-Knowledge: Server only stores encrypted blobs, never sees plaintext or passwords
Note Management
- Auto-Save: Automatic saving as you type
- Custom URLs: Set custom note URLs with availability checking
- Self-Destructing Notes: Max view count limits
- Time-Based Expiration: Set expiration (1 hour to 1 month)
- Automatic Cleanup: Expired and inactive notes are deleted
Editor
- Syntax Highlighting: 190+ languages via highlight.js (lazy-loaded)
- Line Numbers: Synchronized with scroll
- Dark/Light Theme: Persistent theme toggle
Tech Stack
Platform
- Cloudflare Workers - Serverless edge computing
- Cloudflare Durable Objects - Stateful WebSocket coordination for real-time sync
- Cloudflare D1 - Serverless SQLite database at the edge
- Cloudflare Assets - Global CDN for static files
Backend
| Technology | Purpose |
|---|---|
| Hono | Lightweight web framework for Workers |
| TypeScript | Type-safe development |
| Wrangler | Cloudflare development & deployment CLI |
Frontend
| Technology | Purpose |
|---|---|
| Svelte 5 | Reactive UI framework with Runes |
| Vite | Build tool and dev server |
| TypeScript | Type safety |
Real-Time Collaboration
| Technology | Purpose |
|---|---|
| Yjs | CRDT framework for conflict-free collaborative editing |
| y-protocols | Yjs sync and awareness protocols |
Styling & UI
| Technology | Purpose |
|---|---|
| Tailwind CSS | Utility-first CSS framework |
| shadcn-svelte | High-quality component library |
| bits-ui | Headless UI primitives |
| @lucide/svelte | Icon library |
Specialized Libraries
| Technology | Purpose |
|---|---|
| highlight.js | Syntax highlighting for 190+ languages |
| @internationalized/date | Internationalized date handling |
Security & Cryptography
- Web Crypto API - Browser-native cryptography
- AES-GCM encryption (256-bit)
- PBKDF2 key derivation
- SHA-256 hashing
Architecture
System Design
┌─────────────────┐
│ Client App │
│ (Svelte 5) │
│ + Yjs CRDT │
└────────┬────────┘
│
│ HTTPS/WSS
│
┌────────▼────────────────────────────────────┐
│ Cloudflare Workers (Edge Network) │
│ ┌──────────────────────────────────────┐ │
│ │ Hono API Router │ │
│ │ • GET/POST/PUT /api/notes │ │
│ │ • WebSocket upgrade handler │ │
│ └──────────┬──────────────┬────────────┘ │
│ │ │ │
│ ┌─────────▼─────┐ ┌───▼──────────────┐ │
│ │ D1 Database │ │ Durable Objects │ │
│ │ (SQLite) │ │ (Yjs Server) │ │
│ └───────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────┘
Real-Time Collaboration Flow (Yjs CRDT)
- Client connects via WebSocket to Cloudflare Worker
- Worker validates session and upgrades connection to Durable Object
- Durable Object sends initial Yjs state to the client
- Yjs CRDT handles all text operations locally with immediate UI updates
- Updates are encoded as binary Yjs updates and sent to server
- Server broadcasts updates to all connected clients
- Awareness protocol syncs cursor positions and user presence
- Persistence occurs periodically (debounced 5s or every 50 operations)
Database Schema
CREATE TABLE notes ( id TEXT PRIMARY KEY, content TEXT NOT NULL, -- Plaintext or encrypted blob yjs_state BLOB, -- Yjs document state for fast restore syntax_highlight TEXT DEFAULT 'plaintext', view_count INTEGER DEFAULT 0, max_views INTEGER, expires_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, version INTEGER DEFAULT 1, last_session_id TEXT, is_encrypted INTEGER DEFAULT 0, -- E2E encryption flag last_accessed_at INTEGER -- Tracks when note was last viewed ); CREATE INDEX idx_notes_expires_at ON notes(expires_at); CREATE INDEX idx_notes_created_at ON notes(created_at); CREATE INDEX idx_notes_last_accessed_at ON notes(last_accessed_at);
Quick Start
Prerequisites
- Node.js 18+
- npm or pnpm
- Cloudflare account (for deployment)
Installation
# Clone the repository git clone <repo-url> cd yPad # Install dependencies npm install # Run migrations (creates local database automatically) npm run db:migrate # Start development server npm run dev
Visit http://127.0.0.1:8787 to see the app.
Development
Development Scripts
yPad includes automated development and deployment scripts for both Windows and Mac/Linux:
Quick Dev Server Restart (Recommended)
Windows (PowerShell):
Mac/Linux (Bash):
This script automatically:
- Kills any processes running on port 8787
- Runs database migrations if needed
- Builds the frontend
- Starts the dev server
Manual Development
# Start dev server with hot reload npm run dev # Build frontend only npm run build # Preview production build locally npm run preview
The dev server runs with:
- Local D1 database (
--local) - Persistent state in
.wrangler/state(--persist-to) - Hot module replacement
- Automatic Svelte compilation
- Rate limiting disabled (via
DISABLE_RATE_LIMITSinwrangler.toml)
Database Commands
# Create new D1 database npm run db:create # Apply migrations locally npm run db:migrate # Apply migrations to production npm run db:migrate:prod
Testing
Unit Tests (Vitest)
# Run all unit tests npm test # Run tests in watch mode npm run test:watch # Run tests with coverage npm run test:coverage
Unit test suites cover:
- API validation and constants
- WebSocket client behavior
- Cryptography utilities
- Message handlers
- Editor and collaboration hooks
- Rate limiting (token bucket, sliding window)
E2E Tests (Playwright)
# Run all e2e tests npm run e2e # Run e2e tests with UI npx playwright test --ui # Run specific test suite npx playwright test e2e/latency-sync.spec.ts
E2E test suites:
Latency Sync (latency-sync.spec.ts) - 13 tests
- Race conditions with high latency (client types while PUT in flight)
- Concurrent insertions at different positions
- Concurrent insertions at same position (CRDT resolution)
- Backspace and Delete key operations
- Word deletion (Ctrl+Backspace) with concurrent edits
- Selection replacement (typing over selection)
- Tab key insertion with concurrent edits
- Rapid fire typing from 3 clients simultaneously
- Enter key (newlines) with concurrent edits
- Cut (Ctrl+X) operation with concurrent edits
- Mixed operations (insert, delete, replace all at once)
- Stress test: 45 rapid operations from 3 clients
- Extreme latency difference (50ms vs 300ms clients)
Collaborative Editing (collaborative-editing.spec.ts) - 14 tests
- Local cursor stays in place when remote user types after cursor
- Cursor shifts correctly when remote user types before cursor
- Selection preserved during remote edits elsewhere
- Rapid typing from remote users doesn't disrupt local cursor
- Multi-client simultaneous editing with stable cursors
- Edge cases: cursor at position 0, end of document, multi-line
- Concurrent insertions at different positions
- Concurrent insertions at same position
- Selection replacement with concurrent edits
- Content persistence after fast typing
Remote Cursors (remote-cursors.spec.ts) - 8 tests
- Cursor visibility for remote users
- Cursor position updates in real-time
- Cursor color consistency
- Cursor cleanup on user disconnect
- Cursor shifts when text inserted/deleted before it
- New client joining sees existing remote cursors
E2E Encryption Security (e2e-encryption.spec.ts) - 9 tests
- Passwords are NEVER sent to the server
- Plaintext content never leaves the browser
- WebSocket messages never contain password or plaintext
- Multiple password attempts don't leak to server
Editor Limits (editor-limits.spec.ts) - 16 tests
- First user edit permission and typing capability
- Connection status shows client ID and editor count
- Viewer count shown separately from editor count
- Encrypted notes bypass editor limit
- Editor limit banner when 11th user tries to edit
- Retry button allows editing after an editor leaves
Rate Limiting (rate-limiting.spec.ts) - 7 tests
- REST API rate limits per endpoint
- WebSocket operation rate limits
- Token bucket refill behavior
Status Indicator (status-indicator.spec.ts) - 7 tests
- Connection status display
- Save status indicators
- Sync state visualization
Syntax Highlighting (syntax-highlighting.spec.ts) - 9 tests
- JavaScript, Python, TypeScript highlighting in light theme
- JavaScript, Python, TypeScript highlighting in dark theme
- Switching back to Plain Text removes highlighting
- Note with language pre-set loads with highlighting after reload
- Highlighting persists after theme toggle
Project Structure
yPad/
├── client/ # Frontend Svelte application
│ ├── components/ # Feature components
│ │ ├── Banners/ # Notification banners
│ │ │ ├── EditorLimitBanner.svelte
│ │ │ ├── EncryptionDisabledBanner.svelte
│ │ │ ├── EncryptionEnabledBanner.svelte
│ │ │ ├── FinalViewBanner.svelte
│ │ │ ├── NoteDeletedBanner.svelte
│ │ │ └── ReloadBanner.svelte
│ │ ├── Dialogs/ # Modal dialogs
│ │ │ ├── ConflictDialog.svelte
│ │ │ ├── InfoDialog.svelte
│ │ │ ├── PasswordDialog.svelte
│ │ │ └── RemovePasswordDialog.svelte
│ │ ├── Editor/ # Editor components
│ │ │ ├── EditorView.svelte
│ │ │ └── LineNumbers.svelte
│ │ ├── Header/ # Header components
│ │ │ ├── AppHeader.svelte
│ │ │ ├── ConnectionStatus.svelte
│ │ │ ├── StatusIndicator.svelte
│ │ │ └── UrlDisplay.svelte
│ │ └── Toolbar/ # Toolbar components
│ │ ├── LanguageSelector.svelte # Lazy-loaded language list
│ │ ├── MaxViewsInput.svelte
│ │ ├── OptionsPanel.svelte
│ │ └── PasswordInput.svelte
│ ├── lib/
│ │ ├── components/
│ │ │ ├── RemoteCursor.svelte
│ │ │ └── ui/ # shadcn-svelte components
│ │ ├── hooks/ # Svelte 5 hooks
│ │ │ ├── useCollaboration.svelte.ts
│ │ │ ├── useEditor.svelte.ts
│ │ │ ├── useNoteOperations.svelte.ts
│ │ │ ├── useNoteState.svelte.ts
│ │ │ ├── useSecurity.svelte.ts
│ │ │ └── useWebSocketConnection.svelte.ts
│ │ ├── realtime/
│ │ │ └── WebSocketClient.ts # WebSocket client with Yjs sync
│ │ ├── yjs/
│ │ │ └── YjsManager.ts # Yjs document & awareness manager
│ │ ├── stores/
│ │ │ └── theme.svelte.ts
│ │ ├── utils/
│ │ │ ├── cn.ts
│ │ │ └── highlighter.ts # Lazy-loaded syntax highlighter
│ │ └── crypto.ts # Client-side encryption (AES-GCM)
│ ├── App.svelte # Main app component
│ ├── app.css # Global styles
│ ├── index.html # HTML entry point
│ └── main.ts # JS entry point
├── src/ # Backend Cloudflare Workers
│ ├── durable-objects/
│ │ ├── handlers/
│ │ │ ├── messageHandlers.ts # Yjs update & broadcast handlers
│ │ │ ├── types.ts # Handler context types
│ │ │ └── index.ts
│ │ ├── NoteSessionDurableObject.ts # WebSocket coordinator with Yjs
│ │ └── RateLimiterDurableObject.ts # Per-session rate limiting
│ ├── types/
│ │ └── messages.ts # WebSocket message type definitions
│ ├── types.d.ts # TypeScript type definitions
│ └── index.ts # Hono API server & routes
├── config/ # Configuration files
│ ├── constants.ts # Application constants & limits
│ └── languages.ts # Language options (lazy-loaded)
├── tests/ # Vitest unit tests
│ ├── api/ # API route tests
│ ├── client/ # Client-side tests (crypto, WebSocket)
│ ├── config/ # Configuration tests
│ ├── handlers/ # Message handler tests
│ ├── hooks/ # Svelte hook tests
│ └── rate-limiting/ # Rate limiting tests
├── e2e/ # Playwright e2e tests (83 tests)
│ ├── collaborative-editing.spec.ts # Cursor preservation & editing (14 tests)
│ ├── e2e-encryption.spec.ts # E2E encryption security (9 tests)
│ ├── editor-limits.spec.ts # Editor limit tests (16 tests)
│ ├── latency-sync.spec.ts # CRDT sync with latency (13 tests)
│ ├── rate-limiting.spec.ts # Rate limiting tests (7 tests)
│ ├── remote-cursors.spec.ts # Remote cursor sync tests (8 tests)
│ ├── status-indicator.spec.ts # Status display tests (7 tests)
│ └── syntax-highlighting.spec.ts # Syntax highlighting tests (9 tests)
├── public/ # Static assets
│ ├── icons/ # Favicon icons
│ ├── favicon.ico
│ └── site.webmanifest
├── scripts/ # Automation scripts
│ ├── dev.ps1 # Windows dev server script
│ ├── dev.sh # Mac/Linux dev server script
│ ├── prod.ps1 # Windows production deployment
│ └── prod.sh # Mac/Linux production deployment
├── migrations/ # D1 database migrations
│ ├── 0001_initial_schema.sql
│ └── 0002_add_yjs_state.sql
├── wrangler.toml # Cloudflare Workers config
├── vite.config.ts # Vite build config
├── vitest.config.ts # Vitest test config
├── playwright.config.ts # Playwright e2e test config
└── package.json # Dependencies & scripts
Deployment
Automated Production Deployment (Recommended)
yPad includes automated deployment scripts that handle environment configuration, migrations, building, and deployment:
Setup Environment Configuration
-
Copy the example environment file:
-
Edit
.envwith your production values:# Cloudflare Account ID (optional) ACCOUNT_ID=your-account-id # Worker name for production WORKER_NAME=ypad # D1 Database Configuration DB_NAME=ypad-db DB_ID=your-production-database-id # Durable Objects Configuration DO_SCRIPT_NAME=ypad # Contact Information ABUSE_EMAIL=abuse@example.com
-
Create production database (if not already created):
wrangler d1 create ypad-db # Copy the database_id from output to your .env file
Deploy to Production
Windows (PowerShell):
Mac/Linux (Bash):
The deployment script automatically:
- ✅ Validates
.envconfiguration - ✅ Backs up your local
wrangler.toml - ✅ Generates production
wrangler.tomlfrom.env - ✅ Runs production database migrations
- ✅ Injects environment variables into constants.ts
- ✅ Builds the frontend
- ✅ Deploys to Cloudflare Workers
- ✅ Restores your local
wrangler.tomlandconstants.ts
Features:
- Automatic rollback on failure
- Timestamped backups of
wrangler.toml - Optional account ID support
- Color-coded output with progress tracking
- Environment validation before deployment
Manual Deployment
# Build and deploy
npm run deployThis will:
- Run
npm run buildto build the Svelte frontend - Deploy to Cloudflare Workers using Wrangler
- Upload assets to Cloudflare CDN
Note: Manual deployment uses the wrangler.toml in your repo. For production, you'll need to manually update it with production values.
Configuration
For local development, edit wrangler.toml to configure:
- Worker name
- Database binding
- Durable Objects configuration
- Cron triggers (cleanup schedule)
- Compatibility date
For production deployment, use the .env file (see Automated Production Deployment above).
Usage
Creating a Note
- Visit the homepage
- Start typing in the editor
- A unique 4-character ID is automatically generated with collision detection
- If a collision occurs, the system retries up to 3 times
- After 3 failed attempts, ID length increases to 5 characters
- Process repeats with adaptive length scaling (max 10 characters)
- Note auto-saves every 500ms
Setting a Custom URL
- Click the pencil icon next to the note URL in the header
- Enter desired URL (availability checked in real-time)
- Press Enter or click the checkmark to create a new note with the custom URL
- All settings (content, syntax, password, expiration) are copied to the new note
- Use the navigation icon to go to any existing note by entering its ID
Adding Password Protection
- Click Options button
- Toggle Password Protection
- Enter password (client-side encryption)
- Note content is encrypted before sending to server
- Real-time collaboration is automatically disabled to preserve E2E encryption
Removing Password Protection
- Click the lock icon in the header
- Confirm removal in the dialog
- Content is decrypted and saved without encryption
- Real-time collaboration is automatically re-enabled
Configuring Note Options
Click Options to set:
- Syntax Highlighting: Choose from 150+ languages
- Password Protection: Enable/disable with password
- Max Views: Set view limit - displays remaining views with reset option
- Expiration: Set expiry with live countdown timer and reset option
Real-Time Collaboration
- Share your note URL with others
- Multiple users can edit simultaneously (for non-encrypted notes, max 10 concurrent editors)
- Changes sync in real-time using Yjs CRDT
- Conflicts are automatically resolved
- See other users' cursors with color-coded position indicators
- Visual status indicators in the header:
- Green pulse: Real-time sync active
- User count: Shows your client ID with
+N/Mformat (N other editors, M viewers) - Blue lock: Connected but collaboration disabled (encrypted note)
- Red: Disconnected
- Trash icon: Note has been deleted
- Editor Limit: Maximum 10 concurrent editors per note. Additional users can view in real-time but must wait for a slot to edit
- Note: Real-time collaboration is automatically disabled for password-protected notes to preserve end-to-end encryption
Viewing a Protected Note
- Open note URL
- Enter password in dialog
- Content is decrypted client-side
- View count increments automatically
Final View Handling
When a note reaches its maximum view count:
- The note content is displayed one final time
- A warning banner appears indicating the note has been deleted
- The note is permanently removed from the server
- Copy the content before leaving the page - it cannot be recovered
API Reference
REST Endpoints
GET /api/notes/:id
Retrieve a note by ID. Updates last_accessed_at timestamp.
Response:
{
"id": "abc123",
"content": "Note content (encrypted blob if protected)",
"syntax_highlight": "javascript",
"view_count": 5,
"max_views": null,
"expires_at": null,
"is_encrypted": false,
"is_last_view": false
}Side Effects:
- Increments
view_count(for non-encrypted notes only) - Updates
last_accessed_attimestamp - If
is_last_viewis true, note is deleted after response
Note: For encrypted notes, content is returned as an encrypted blob. Decryption happens client-side.
POST /api/notes
Create a new note. Initializes last_accessed_at to current timestamp.
Body:
{
"id": "custom-id",
"content": "Note content (or encrypted blob)",
"syntax_highlight": "plaintext",
"max_views": null,
"expires_in": null,
"is_encrypted": false
}Side Effects:
- Sets
last_accessed_atto current timestamp
PUT /api/notes/:id
Update an existing note.
Body:
{
"content": "Updated content",
"syntax_highlight": "javascript",
"max_views": 10,
"expires_in": 86400000,
"clear_expiration": false
}Response:
{
"version": 2,
"expires_at": 1704067200000
}Notes:
- Setting
max_viewsresetsview_countto 0 expires_atis computed server-side fromexpires_in- Use
clear_expiration: trueto remove expiration
DELETE /api/notes/:id
Delete a note and cleanup Durable Object state.
Query Parameters:
session_id(optional): Session ID for WebSocket cleanup
POST /api/notes/:id/view
Confirm view for encrypted notes after successful client-side decryption.
Response:
{
"view_count": 6,
"is_last_view": false
}Note: This endpoint is only used for encrypted notes. View count is incremented after the client successfully decrypts the content.
GET /api/check/:id
Check if a custom ID is available.
Response:
WebSocket Protocol
Connection
ws://localhost:8787/api/notes/:id/ws
Side Effects:
- Updates
last_accessed_attimestamp when connection is established
Message Types
Yjs Sync (server → client):
{
"type": "yjs_sync",
"state": "<base64-encoded Yjs state>",
"seqNum": 0,
"clientId": "unique-client-id",
"syntax": "javascript"
}Yjs Update (client ↔ server):
{
"type": "yjs_update",
"update": "<base64-encoded Yjs update>",
"clientId": "client-id",
"seqNum": 1
}Yjs Acknowledgment (server → client):
{
"type": "yjs_ack",
"seqNum": 2
}Awareness Update (client ↔ server):
{
"type": "awareness_update",
"update": "<base64-encoded awareness state>",
"clientId": "abc123",
"seqNum": 3
}Note Status (server → client, every 10 seconds):
{
"type": "note_status",
"view_count": 5,
"max_views": 10,
"expires_at": 1704067200000
}User Joined (server → client):
{
"type": "user_joined",
"clientId": "abc123",
"connectedUsers": ["abc123", "def456", "ghi789"],
"activeEditorCount": 2,
"viewerCount": 1,
"seqNum": 6
}User Left (server → client):
{
"type": "user_left",
"clientId": "abc123",
"connectedUsers": ["def456", "ghi789"],
"activeEditorCount": 1,
"viewerCount": 1,
"seqNum": 7
}Request Edit (client → server):
{
"type": "request_edit",
"clientId": "abc123",
"sessionId": "session-uuid"
}Request Edit Response (server → client):
{
"type": "request_edit_response",
"canEdit": true,
"activeEditorCount": 3,
"viewerCount": 2
}Editor Count Update (server → client):
{
"type": "editor_count_update",
"activeEditorCount": 4,
"viewerCount": 1,
"seqNum": 8
}Syntax Change (client ↔ server):
{
"type": "syntax_change",
"syntax": "javascript",
"clientId": "abc123",
"seqNum": 8
}Encryption Changed (server → client):
{
"type": "encryption_changed",
"is_encrypted": true,
"has_password": true
}Note Deleted (server → client):
{
"type": "note_deleted",
"deletedByCurrentUser": false
}Error (server → client):
{
"type": "error",
"message": "Error description"
}Key Features Explained
Yjs CRDT
yPad uses Yjs, a high-performance CRDT (Conflict-free Replicated Data Type) implementation for real-time collaborative editing. CRDTs mathematically guarantee that all users converge to the same document state, regardless of network conditions or edit ordering.
How It Works
Unlike traditional Operational Transform (OT), CRDTs don't require a central server to resolve conflicts. Each character in the document has a unique identifier, and Yjs uses these identifiers to merge concurrent edits deterministically.
Example: If User A inserts "hello" at position 5 while User B simultaneously deletes characters 3-7:
- Each edit is applied independently using Yjs's merge algorithm
- No transformation needed - CRDT guarantees convergence
- Both users automatically end up with identical documents
Key Advantages
- Guaranteed Convergence: Mathematical proof that all clients reach the same state
- No Central Arbitration: Conflicts resolved locally without server coordination
- Offline Support Ready: Changes can be synced after reconnection
- Cursor Awareness: Real-time cursor positions via Yjs Awareness protocol
- Efficient Updates: Only deltas (changes) are transmitted, not full content
Architecture
The system uses Yjs with a server-relay model:
- Local First: When you type, Yjs updates the local document immediately
- Binary Updates: Changes are encoded as compact binary updates
- Server Relay: Server receives updates and broadcasts to other clients
- State Persistence: Server maintains authoritative Yjs state in the database
Sync Protocol
yPad uses the y-protocols library for synchronization:
- Initial Sync: New clients receive the full Yjs state on connection
- Incremental Updates: Subsequent changes sent as binary update messages
- Awareness Protocol: Cursor positions and user presence synced separately
- Gap Detection: Out-of-order messages buffered; persistent gaps trigger resync
Yjs Document Structure
// Server-side Yjs document const yjsDoc = new Y.Doc(); const yjsText = yjsDoc.getText('content'); // Client receives state as base64-encoded binary const state = Y.encodeStateAsUpdate(yjsDoc);
Why Yjs Over OT?
| Aspect | OT | Yjs CRDT |
|---|---|---|
| Conflict Resolution | Server transforms operations | Automatic via unique IDs |
| Complexity | O(n²) transform pairs | O(1) merge |
| Offline Support | Difficult | Built-in |
| Convergence | Requires careful implementation | Mathematically guaranteed |
Awareness Protocol
yPad uses the Yjs Awareness protocol for cursor synchronization and user presence:
- Cursor Positions: Each user's cursor position synced in real-time
- User Colors: Unique colors assigned per client for visual distinction
- Presence Detection: Automatic tracking of connected/disconnected users
- Low Overhead: Awareness updates are lightweight and don't affect document state
Client-Side Encryption
For password-protected notes:
- User enters password
- Password derives encryption key (PBKDF2, 100,000 iterations)
- Content encrypted with AES-GCM before transmission
- Only encrypted blob sent to server - password never leaves the browser
- Server stores encrypted content, never sees plaintext or password
- On retrieval, server returns encrypted blob (no password verification)
- Client decrypts content locally - decryption success validates password
- Real-time collaboration is automatically disabled to maintain E2E encryption
- All connected clients notified when encryption status changes
Security Guarantees (verified by E2E tests):
- Passwords are never transmitted to the server in any form
- Plaintext content is never sent after encryption is enabled
- Server only stores and serves encrypted blobs
Editor Limits
yPad limits concurrent editors to prevent resource exhaustion and ensure a smooth editing experience for all users.
How It Works
- Maximum 10 active editors per note at any time
- Active editor: A user who has sent a Yjs update (typed/edited) within the last 60 seconds
- Viewer: A connected user who hasn't edited recently (can view in real-time)
- Encrypted notes bypass the limit: Since real-time collaboration is disabled for encrypted notes, they're always editable
User Experience
-
Status Display: Header shows
clientId +N/MformatclientId: Your 4-character identifier+N: Number of other active editors/M: Number of viewers (users who haven't typed recently)- Example:
a1b2 +3/5= you + 3 other editors + 5 viewers
-
Becoming an Editor: When you start typing:
- Client requests edit permission from server
- If under limit (< 10 editors), you're granted editing
- Your status updates from viewer to active editor
- All clients receive real-time count updates
-
At the Limit: When 10 editors are active:
- New users can still view the note in real-time
- Attempting to edit shows a yellow warning banner
- "Retry" button lets you check if a slot opened up
- When an editor leaves or times out (60s), a slot opens
Configuration
Editor limits are configured in config/constants.ts:
export const EDITOR_LIMITS = { MAX_ACTIVE_EDITORS: 10, // Maximum concurrent editors per note ACTIVE_TIMEOUT_MS: 60_000, // Time before an idle editor becomes a viewer } as const;
Max Views & Expiration
yPad supports two types of note lifecycle limits:
Max Views:
- Set a maximum number of views (1-1000)
- View count resets when max views is set (counts from time of setting)
- Remaining views displayed in options panel
- Note automatically deleted when limit reached
- Final view shows content with warning banner
Expiration:
- Set time-based expiration (1 hour to 1 month)
- Live countdown timer in options panel
- Expiration time computed server-side for accuracy
- Note automatically deleted by cron job when expired
Both limits can be reset (removed) after being set.
Automatic Cleanup
A cron trigger runs every 15 minutes to clean up notes:
// Triggered by: crons = ["*/15 * * * *"] async scheduled(event, env, ctx) { const now = Date.now(); const inactiveThreshold = now - (90 * 24 * 60 * 60 * 1000); // 90 days // Delete expired notes (by expires_at timestamp) await env.DB.prepare( 'DELETE FROM notes WHERE expires_at IS NOT NULL AND expires_at <= ?' ).bind(now).run(); // Delete inactive notes (not accessed in 90 days) await env.DB.prepare( 'DELETE FROM notes WHERE last_accessed_at IS NOT NULL AND last_accessed_at <= ?' ).bind(inactiveThreshold).run(); }
Cleanup Rules:
- Notes with
expires_atpast current time are deleted - Notes not accessed in 90 days (configurable via
INACTIVE_NOTE_EXPIRY_DAYSconstant) are deleted - Access tracking updates on:
- GET
/api/notes/:id- Note view - WebSocket connection to
/api/notes/:id/ws - POST
/api/notes- Note creation (initialized to current time)
- GET
Rate Limiting
yPad implements rate limiting to prevent abuse while allowing normal usage patterns.
REST API Rate Limits
Per-session rate limiting using Cloudflare Durable Objects:
| Endpoint | Limit | Window |
|---|---|---|
POST /api/notes (create) |
10 requests | per minute |
GET /api/notes/:id (read) |
60 requests | per minute |
PUT /api/notes/:id (update) |
30 requests | per minute |
DELETE /api/notes/:id (delete) |
20 requests | per minute |
GET /api/notes/:id/ws (WebSocket upgrade) |
30 requests | per minute |
Rate Limit Response:
HTTP/1.1 429 Too Many Requests Retry-After: 45 {"error": "Rate limit exceeded"}
The Retry-After header indicates how many seconds to wait before retrying.
WebSocket Rate Limits
Token bucket algorithm for real-time messages:
| Setting | Value | Description |
|---|---|---|
| Messages per second | 25 | Sustained rate limit |
| Burst allowance | 100 | Tokens for paste operations |
| Max message size | 128 KB | Maximum WebSocket message size |
How it works:
- Each connection starts with 100 tokens (burst allowance)
- Tokens refill at 25 per second
- Each message consumes 1 token
- Normal typing (5-10 chars/sec) never hits the limit
- Large pastes consume burst tokens but recover quickly
Violation Handling:
- First violations: Warning message sent via WebSocket
- After 10 violations: Connection closed with code 1008
{"type": "error", "message": "Rate limit exceeded. Please slow down."}Local Development
Rate limiting is disabled by default in local development via DISABLE_RATE_LIMITS=true in wrangler.toml. This allows E2E tests to run without hitting rate limits. In production, this variable is not set, so rate limiting is enforced.
Configuration
All rate limits are configurable in config/constants.ts:
export const RATE_LIMITS = { API: { CREATE_PER_MINUTE: 10, READ_PER_MINUTE: 60, UPDATE_PER_MINUTE: 30, DELETE_PER_MINUTE: 20, WS_UPGRADE_PER_MINUTE: 30, }, WEBSOCKET: { OPS_PER_SECOND: 25, BURST_ALLOWANCE: 100, MAX_MESSAGE_SIZE: 131072, // 128 KB }, PENALTY: { DISCONNECT_THRESHOLD: 10, WARNING_MESSAGE: 'Rate limit exceeded. Please slow down.', }, } as const;
Contributing
Pull requests are welcome! For major changes, please open an issue first to discuss what you would like to change.
License
MIT
Acknowledgments
- Inspired by notepad.pw
- Built with Cloudflare Workers
- UI components from shadcn-svelte