I wanted unlimited cloud storage that:
- Costs nothing to run
- Has no server I need to maintain
- Doesn't require trusting a third party with my files
- Works from any browser without installing an app
Google Drive gives 15 GB free. Dropbox gives 2 GB. Self-hosted solutions like Nextcloud require a server.
Then I remembered: Telegram has no storage quota. Files you send to Saved Messages or private channels stay there forever. And Telegram has an official API.
So I built *TGDrive *— a Google Drive-style interface that runs entirely in your browser and uses Telegram as the storage backend. No server. No subscription. No storage limit.
🔗 Live: https://fbpatel003.github.io/tgdrive
🔗 GitHub: https://github.com/fbpatel003/tgdrive
Architecture
Your Browser
├── React UI (TGDrive interface)
├── GramJS (Telegram MTProto client — WebSocket)
├── Service Worker (video range request handler)
├── IndexedDB (session + metadata + thumbnail cache)
└── WebSocket ──────────────────► Telegram Servers
└── Your files stored here permanently
# No TGDrive server exists between you and Telegram
The key insight: Telegram's MTProto protocol is a WebSocket-based protocol. GramJS, a JavaScript implementation, can run directly in the browser. Your browser becomes the Telegram client.
Challenge 1: Running GramJS in Vite
GramJS was written for Node.js. It uses Buffer, process, crypto, stream, os and other Node built-ins that don't exist in browsers.
The polyfill setup:
npm install -D vite-plugin-node-polyfills
// vite.config.ts
import { nodePolyfills } from 'vite-plugin-node-polyfills'
export default defineConfig({
plugins: [
react(),
nodePolyfills({
include: ['buffer', 'process', 'stream', 'util', 'events', 'path', 'crypto'],
globals: { Buffer: true, global: true, process: true },
}),
],
})
The os module problem:
GramJS calls os.type() to identify the client device. There's no os module in browsers. The solution: a physical stub file aliased in Vite:
// src/stubs/os.ts
export function type() { return 'Browser' }
export function platform() { return 'browser' }
export function release() { return '0.0.0' }
// ... etc
export default { type, platform, release, ... }
// vite.config.ts
resolve: {
alias: {
os: path.resolve(__dirname, 'src/stubs/os.ts'),
},
},
The socket problem:
GramJS defaults to ConnectionTCPObfuscated which uses Node's net.Socket. In the browser this crashes with net.Socket is not a constructor. The fix: pass PromisedWebSockets explicitly:
import { PromisedWebSockets } from "telegram/extensions/PromisedWebSockets";
const client = new TelegramClient(session, apiId, apiHash, {
connectionRetries: 5,
useWSS: true,
networkSocket: PromisedWebSockets, // ← critical
});
Challenge 2: AUTH_KEY_DUPLICATED on page refresh
This one took the longest to debug.
What happens:
MTProto authenticates via auth keys — cryptographic keys negotiated between client and server. When you refresh the page:
- A new
TelegramClientis created with the session string from IndexedDB -
connect()sometimes renegotiates a new auth key -
StringSession.save()just returns a string — it doesn't write anywhere - The new key exists on Telegram's server but the old key is still in IndexedDB
- Next refresh: Telegram sees two keys registered →
AUTH_KEY_DUPLICATED
The deeper problem:
StringSession only stores ONE auth key (for the main DC). Telegram uses multiple data centres (DCs). When you download a file from DC2, getAuthKey(dc2) returns undefined → GramJS generates a fresh DC2 key → same duplication problem.
The fix:
Subclass StringSession to intercept every key write and persist it to IndexedDB immediately:
class PersistentSession extends StringSession {
private _dcKeys: Map<number, AuthKey> = new Map();
override save(): string {
const str = super.save();
if (str) {
// Write main session back to IndexedDB immediately
db.credentials.get(1).then((creds) => {
if (creds && creds.sessionString !== str) {
db.credentials.put({ ...creds, sessionString: str });
}
});
}
return str;
}
override getAuthKey(dcId?: number): AuthKey | undefined {
if (dcId && dcId !== this.dcId) {
return this._dcKeys.get(dcId); // Return stored DC key
}
return super.getAuthKey(dcId);
}
override setAuthKey(authKey?: AuthKey, dcId?: number): undefined {
if (dcId && dcId !== this.dcId) {
if (authKey) {
this._dcKeys.set(dcId, authKey);
this._persistDcKeys(); // Save to IndexedDB
}
return undefined;
}
return super.setAuthKey(authKey, dcId);
}
}
Challenge 3: Video streaming without restarts
My first approach: collect chunks from iterDownload, build a Blob URL every second, set it as <video src>.
The problem: Every time you change <video src>, the video element resets and restarts from the beginning. With a new URL every second, the video constantly restarts until the full file is downloaded.
The real solution: Service Worker + Range Requests
The browser's <video> element natively issues HTTP Range requests (Range: bytes=0-65535). A Service Worker can intercept these and respond with the exact bytes requested.
Browser <video> → Range request → Service Worker → GramJS chunk → 206 Response
The flow:
- Register a stream with the SW before setting
<video src> - Give
<video>a URL like/tgdrive-stream/file-id - SW intercepts the range request, waits for data
- Main thread streams 512KB chunks from Telegram via
iterDownload, posts each to SW - SW responds with exactly the bytes the player asked for
- Video starts playing after the first chunk, seeks without re-downloading
// Main thread
postToSW({ type: 'STREAM_REGISTER', streamId, fileSize, mimeType });
onUrl(`/tgdrive-stream/${streamId}`); // Give video element the URL immediately
for await (const chunk of client.iterDownload({ ... })) {
postToSW({ type: 'STREAM_CHUNK', streamId, chunk: ab });
}
postToSW({ type: 'STREAM_DONE', streamId });
// Service Worker
self.addEventListener('fetch', (event) => {
if (!url.pathname.startsWith('/tgdrive-stream/')) return;
event.respondWith(handleRange(event.request, streamId));
});
async function handleRange(request, streamId) {
const rangeHeader = request.headers.get('Range');
// Parse range, wait for chunks, respond with 206 Partial Content
}
Challenge 4: File metadata accuracy
When you upload a file to Telegram, it sometimes renames it or strips metadata. A photo becomes photo_123.jpg, a document loses its extension.
The fix: Store metadata as a JSON caption on every uploaded message:
const meta = {
v: 1,
name: file.name,
size: file.size,
mime: file.type,
uploadedAt: Date.now(),
};
await client.sendFile(peer, {
file: uploadedFile,
caption: JSON.stringify(meta),
forceDocument: true, // Always upload as document, never as photo
});
On retrieval, parse the caption first. Fall back to Telegram's document attributes for old files:
const meta = parseCaption(msg.message);
if (meta) {
name = meta.name; size = meta.size; mimeType = meta.mime;
} else {
// Fallback: read from Telegram document attributes
}
forceDocument: true also prevents auto-download on Telegram mobile — files appear as attachments, not media.
Challenge 5: GitHub Pages SPA routing
React Router + GitHub Pages = 404 on direct URL access or refresh.
The solution involves three parts:
-
public/404.html— catches 404s, extracts the path, redirects to/?p=/drive/123 -
index.htmlscript — reads?p=parameter, restores path withhistory.replaceState -
BrowserRouter basename— set toimport.meta.env.BASE_URLso React Router strips the repo name from routes
// App.tsx
const basename = import.meta.env.BASE_URL.replace(/\/$/, '') || '/';
// ...
<BrowserRouter basename={basename}>
// vite.config.ts
const repoName = process.env.GITHUB_REPOSITORY
? `/${process.env.GITHUB_REPOSITORY.split('/')[1]}/`
: '/'
export default defineConfig({
base: repoName,
// ...
})
GITHUB_REPOSITORY is an environment variable automatically set by GitHub Actions to owner/repo-name. No manual config needed.
Result
TGDrive now has:
- Unlimited storage, 2 GB per file
- Folder organisation with create/delete
- Multi-file drag-and-drop upload with real-time progress
- Lazy-loaded image thumbnails (cached in IndexedDB, evicted after 30 days)
- Video streaming via Service Worker (no full-file buffering)
- File search across all folders
- Persistent login via per-DC auth key persistence
Try it
🔗 Live: https://fbpatel003.github.io/tgdrive
🔗 GitHub: https://github.com/fbpatel003/tgdrive
You'll need API credentials from my.telegram.org — takes 2 minutes to get.
If you found any of these solutions interesting, star the repo — it helps others find it. And if you have questions about any of the implementation details, ask in the comments — I'm happy to go deeper.




Top comments (0)