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
| Technology | Purpose |
|---|---|
| Hono | Lightweight web framework for Workers |
| TypeScript | Type-safe development |
| Wrangler | Cloudflare development & deployment CLI |
| Technology | Purpose |
|---|---|
| Svelte 5 | Reactive UI framework with Runes |
| Vite | Build tool and dev server |
| TypeScript | Type safety |
| Technology | Purpose |
|---|---|
| Yjs | CRDT framework for conflict-free collaborative editing |
| y-protocols | Yjs sync and awareness protocols |
| Technology | Purpose |
|---|---|
| Tailwind CSS | Utility-first CSS framework |
| shadcn-svelte | High-quality component library |
| bits-ui | Headless UI primitives |
| @lucide/svelte | Icon library |
| Technology | Purpose |
|---|---|
| highlight.js | Syntax highlighting for 190+ languages |
| @internationalized/date | Internationalized date handling |
┌─────────────────┐
│ 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) │ │
│ └───────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────┘
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);
# 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.
yPad includes local development scripts for both Windows and Mac/Linux:
Windows (PowerShell):
.\scripts\dev.ps1
Mac/Linux (Bash):
./scripts/dev.sh
This script automatically:
# 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).wrangler/state (--persist-to)DISABLE_RATE_LIMITS in wrangler.toml)# Create new D1 database
npm run db:create
# Apply migrations locally
npm run db:migrate
# Apply migrations to production
npm run db:migrate:prod
# 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:
# 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
Collaborative Editing (collaborative-editing.spec.ts) - 14 tests
Remote Cursors (remote-cursors.spec.ts) - 8 tests
E2E Encryption Security (e2e-encryption.spec.ts) - 9 tests
Editor Limits (editor-limits.spec.ts) - 16 tests
Rate Limiting (rate-limiting.spec.ts) - 7 tests
Status Indicator (status-indicator.spec.ts) - 7 tests
Syntax Highlighting (syntax-highlighting.spec.ts) - 9 tests
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/ # Local development and config helpers
│ ├── dev.ps1 # Windows dev server script
│ ├── dev.sh # Mac/Linux dev server script
│ └── writeWranglerConfig.mjs # Generates wrangler.toml from CI/local env
├── 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
Production deploys are handled by the CI workflow in .github/workflows/ci.yml.
Every pull request runs formatting, type checks, lint, build, unit tests, and Playwright e2e tests.
Pushes to main or master run the same checks and then deploy to Cloudflare Workers.
The deployment job:
pnpm install --frozen-lockfilepnpm run check:cipnpm run buildpnpm run test:unitpnpm run e2ewrangler.toml from repository variables and secretspnpm run cf:deployConfigure these GitHub Actions secrets:
CLOUDFLARE_ACCOUNT_IDCLOUDFLARE_API_TOKENCLOUDFLARE_D1_DATABASE_ID (or set WRANGLER_D1_DATABASE_ID as a repository variable)Configure these GitHub Actions variables as needed:
WRANGLER_COMPATIBILITY_DATEWRANGLER_NAMEWRANGLER_ROUTE or WRANGLER_ROUTES_JSONWRANGLER_D1_DATABASE_NAMEWRANGLER_D1_DATABASE_IDDISABLE_RATE_LIMITSCreate the production D1 database once before the first deploy:
wrangler d1 create ypad-db
Copy the generated database ID into CLOUDFLARE_D1_DATABASE_ID or WRANGLER_D1_DATABASE_ID.
When schema migrations change, apply them to production with:
npm run db:migrate:prod
For local development, edit wrangler.toml to configure:
For production deployment, set the GitHub Actions secrets and repository variables described above.
Click Options to set:
+N/M format (N other editors, M viewers)When a note reaches its maximum view count:
GET /api/notes/:idRetrieve 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:
view_count (for non-encrypted notes only)last_accessed_at timestampis_last_view is true, note is deleted after responseNote: For encrypted notes, content is returned as an encrypted blob. Decryption happens client-side.
POST /api/notesCreate 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:
last_accessed_at to current timestampPUT /api/notes/:idUpdate 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:
max_views resets view_count to 0expires_at is computed server-side from expires_inclear_expiration: true to remove expirationDELETE /api/notes/:idDelete a note and cleanup Durable Object state.
Query Parameters:
session_id (optional): Session ID for WebSocket cleanupPOST /api/notes/:id/viewConfirm 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/:idCheck if a custom ID is available.
Response:
{
"available": true
}
ws://localhost:8787/api/notes/:id/ws
Side Effects:
last_accessed_at timestamp when connection is establishedYjs 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"
}
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.
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:
The system uses Yjs with a server-relay model:
yPad uses the y-protocols library for synchronization:
// 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);
| 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 |
yPad uses the Yjs Awareness protocol for cursor synchronization and user presence:
For password-protected notes:
Security Guarantees (verified by E2E tests):
yPad limits concurrent editors to prevent resource exhaustion and ensure a smooth editing experience for all users.
Status Display: Header shows clientId +N/M format
clientId: Your 4-character identifier+N: Number of other active editors/M: Number of viewers (users who haven't typed recently)a1b2 +3/5 = you + 3 other editors + 5 viewersBecoming an Editor: When you start typing:
At the Limit: When 10 editors are active:
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;
yPad supports two types of note lifecycle limits:
Max Views:
Expiration:
Both limits can be reset (removed) after being set.
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:
expires_at past current time are deletedINACTIVE_NOTE_EXPIRY_DAYS constant) are deleted/api/notes/:id - Note view/api/notes/:id/ws/api/notes - Note creation (initialized to current time)yPad implements rate limiting to prevent abuse while allowing normal usage patterns.
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.
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:
Violation Handling:
{ "type": "error", "message": "Rate limit exceeded. Please slow down." }
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.
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;
Pull requests are welcome! For major changes, please open an issue first to discuss what you would like to change.
MIT