├── bundrop ├── package.json ├── .gitignore ├── tsconfig.json ├── bun.lock ├── README.md └── index.ts /bundrop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | 3 | require("bundrop") 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bundrop", 3 | "version": "0.3.0", 4 | "module": "index.ts", 5 | "type": "module", 6 | "bin": { 7 | "bundrop": "./bundrop" 8 | }, 9 | "author": "Jamon Holmgren ", 10 | "website": "https://github.com/jamonholmgren/bundrop", 11 | "license": "MIT", 12 | "devDependencies": { 13 | "@types/bun": "latest" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies (bun install) 2 | node_modules 3 | 4 | # output 5 | out 6 | dist 7 | *.tgz 8 | 9 | # code coverage 10 | coverage 11 | *.lcov 12 | 13 | # logs 14 | logs 15 | _.log 16 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 | 18 | # dotenv environment variable files 19 | .env 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | .env.local 24 | 25 | # caches 26 | .eslintcache 27 | .cache 28 | *.tsbuildinfo 29 | 30 | # IDEs 31 | .idea 32 | .cursor 33 | .vscode 34 | 35 | # Finder (MacOS) folder config 36 | .DS_Store 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Environment setup & latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "Preserve", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | "noImplicitOverride": true, 23 | 24 | // Some stricter flags (disabled by default) 25 | "noUnusedLocals": false, 26 | "noUnusedParameters": false, 27 | "noPropertyAccessFromIndexSignature": false 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "bundrop", 6 | "devDependencies": { 7 | "@types/bun": "latest", 8 | }, 9 | }, 10 | }, 11 | "packages": { 12 | "@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="], 13 | 14 | "@types/node": ["@types/node@24.6.2", "", { "dependencies": { "undici-types": "~7.13.0" } }, "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang=="], 15 | 16 | "@types/react": ["@types/react@19.2.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA=="], 17 | 18 | "bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="], 19 | 20 | "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 21 | 22 | "undici-types": ["undici-types@7.13.0", "", {}, "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ=="], 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bundrop 2 | 3 | Serve a single file over HTTP so you can temporarily share it with someone. 4 | 5 | Requirements: Bun. 6 | 7 | ## Usage 8 | 9 | ```bash 10 | # Simplest usage 11 | bunx bundrop ./file.zip 12 | # Create a cloudflare tunnel so you can share the file with someone 13 | bunx bundrop --tunnel ./file.zip 14 | # Full options 15 | bunx bundrop --port 9876 --tunnel --debug ./file.zip 16 | ``` 17 | 18 | You'll need a CloudFlare account and `cloudflared` CLI installed to use the temporary tunnel. [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/local-management/create-local-tunnel/). 19 | 20 | ### CloudFlare Tunnel setup (macOS) 21 | 22 | ```bash 23 | # install cloudflared 24 | brew install cloudflared 25 | # login to cloudflare 26 | cloudflared tunnel login 27 | # create a quick temporary tunnel (bundrop will do this for you if you specify --tunnel) 28 | cloudflared tunnel --url http://localhost:9999 29 | ``` 30 | 31 | This will give you a URL like https://temperature-statutes-boulders-shots.trycloudflare.com/. Send this to the person you want to share the file with. 32 | 33 | You can also create a permanent tunnel. See the [Cloudflare Tunnel documentation](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/local-management/create-local-tunnel/) for more details. 34 | 35 | ## License 36 | 37 | MIT 38 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { statSync, readFileSync } from "fs"; 2 | import path from "path"; 3 | import { createInterface } from "readline"; 4 | 5 | // ANSI color codes 6 | const GREEN = "\x1b[32m"; 7 | const DKGRAY = "\x1b[90m"; 8 | const CYAN = "\x1b[36m"; 9 | const RESET = "\x1b[0m"; 10 | 11 | function showHelp() { 12 | console.log(` 13 | Usage: bunx bundrop [-p port] [--debug] [--tunnel] /path/to/file 14 | 15 | Options: 16 | -p, --port Port to serve the file on (default: 8000) 17 | -d, --debug Enable debug logging 18 | -t, --tunnel Automatically enable CloudFlare tunnel 19 | -h, --help Show this help message 20 | 21 | Examples: 22 | bunx bundrop ./document.pdf 23 | bunx bundrop -p 3000 ./image.jpg 24 | bunx bundrop --debug ./video.mp4 25 | bunx bundrop --tunnel ./file.zip 26 | `); 27 | } 28 | 29 | interface ClientInfo { 30 | ip: string; 31 | ua: string; 32 | count: number; 33 | } 34 | 35 | const args = process.argv.slice(2); 36 | 37 | if (args.length < 1) { 38 | console.log(""); 39 | console.error("Usage: bunx bundrop [-p port] [--debug] /path/to/file"); 40 | console.error("Run 'bunx bundrop --help' for more information."); 41 | console.log(""); 42 | process.exit(1); 43 | } 44 | 45 | // --- CLI parsing --- 46 | let port = 8000; 47 | let filePath = ""; 48 | let debug = false; 49 | let autoTunnel = false; 50 | 51 | for (let i = 0; i < args.length; i++) { 52 | const arg = args[i]; 53 | if (arg === "-h" || arg === "--help") { 54 | showHelp(); 55 | process.exit(0); 56 | } else if (arg === "-p" || arg === "--port") { 57 | const val = args[i + 1]; 58 | if (!val || isNaN(Number(val))) { 59 | console.error("Invalid port number."); 60 | process.exit(1); 61 | } 62 | port = Number(val); 63 | i++; 64 | } else if (arg === "-d" || arg === "--debug") { 65 | debug = true; 66 | } else if (arg === "-t" || arg === "--tunnel") { 67 | autoTunnel = true; 68 | } else if (arg === "-v" || arg === "---version") { 69 | try { 70 | const packageJson = JSON.parse(readFileSync("package.json", "utf8")); 71 | console.log(packageJson.version); 72 | } catch { 73 | console.log("unknown"); 74 | } 75 | process.exit(0); 76 | } else { 77 | filePath = arg || ""; 78 | } 79 | } 80 | 81 | if (!filePath) { 82 | console.error( 83 | "Missing file path.\nUsage: bunx bundrop [-p port] [--debug] [--tunnel] /path/to/file" 84 | ); 85 | process.exit(1); 86 | } 87 | 88 | // Filename hacks for convenience 89 | if (filePath === "help") { 90 | showHelp(); 91 | process.exit(0); 92 | } 93 | 94 | if (filePath === "version") { 95 | try { 96 | const packageJson = JSON.parse(readFileSync("package.json", "utf8")); 97 | console.log(packageJson.version); 98 | } catch { 99 | console.log("unknown"); 100 | } 101 | process.exit(0); 102 | } 103 | 104 | const fileName = path.basename(filePath); 105 | const urlPath = Math.random().toString(36).substring(2, 7); 106 | let fileSize: number; 107 | 108 | try { 109 | fileSize = statSync(filePath).size; 110 | } catch (err) { 111 | console.error(`Can't read file: ${err}`); 112 | process.exit(1); 113 | } 114 | 115 | // --- CloudFlare Tunnel onboarding --- 116 | async function askCloudFlareTunnel(): Promise { 117 | const rl = createInterface({ 118 | input: process.stdin, 119 | output: process.stdout, 120 | }); 121 | 122 | rl.setPrompt( 123 | "Do you want to set up a CloudFlare Tunnel URL to your file? (y/n): " 124 | ); 125 | rl.prompt(); 126 | const answer: string = await new Promise((resolve) => 127 | rl.once("line", resolve) 128 | ); 129 | rl.close(); 130 | return answer.toLowerCase().startsWith("y"); 131 | } 132 | 133 | async function checkCloudFlaredInstalled(debug: boolean): Promise { 134 | if (debug) console.log("DEBUG: Checking if cloudflared is installed..."); 135 | try { 136 | const proc = Bun.spawn(["cloudflared", "--version"], { 137 | stdout: "pipe", 138 | stderr: "pipe", 139 | }); 140 | await proc.exited; 141 | const isInstalled = proc.exitCode === 0; 142 | if (debug) 143 | console.log( 144 | `DEBUG: cloudflared check result: ${ 145 | isInstalled ? "installed" : "not installed" 146 | }` 147 | ); 148 | return isInstalled; 149 | } catch (error) { 150 | if (debug) console.log("DEBUG: cloudflared check error:", error); 151 | return false; 152 | } 153 | } 154 | 155 | function showInstallationInstructions() { 156 | const platform = process.platform; 157 | 158 | console.log("Please install the cloudflared CLI first."); 159 | 160 | if (platform === "darwin") { 161 | console.log("On macOS:"); 162 | console.log("brew install cloudflared"); 163 | } else if (platform === "win32") { 164 | console.log("On Windows:"); 165 | console.log("winget install --id Cloudflare.cloudflared"); 166 | } else { 167 | console.log( 168 | "On Linux, follow the installation instructions in the link below." 169 | ); 170 | } 171 | 172 | console.log( 173 | "Go here for full installation instructions: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/" 174 | ); 175 | } 176 | 177 | async function runCloudFlaredTunnel( 178 | port: number, 179 | debug: boolean 180 | ): Promise { 181 | if (debug) 182 | console.log(`DEBUG: Starting cloudflared tunnel for port ${port}...`); 183 | try { 184 | const proc = Bun.spawn( 185 | ["cloudflared", "tunnel", "--url", `http://localhost:${port}`], 186 | { 187 | stdout: "pipe", 188 | stderr: "pipe", 189 | } 190 | ); 191 | 192 | if (debug) 193 | console.log("DEBUG: cloudflared process spawned, waiting for URL..."); 194 | 195 | return new Promise((resolve) => { 196 | let output = ""; 197 | let urlFound = false; 198 | 199 | // Read from stdout 200 | const reader = proc.stdout.getReader(); 201 | const readStdout = async () => { 202 | try { 203 | while (true) { 204 | const { done, value } = await reader.read(); 205 | if (done) break; 206 | 207 | const chunk = new TextDecoder().decode(value); 208 | output += chunk; 209 | 210 | if (debug) console.log("DEBUG: stdout chunk:", chunk); 211 | 212 | // Look for the tunnel URL in the output 213 | const urlMatch = output.match( 214 | /https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/ 215 | ); 216 | if (urlMatch && !urlFound) { 217 | urlFound = true; 218 | if (debug) console.log(`DEBUG: Found tunnel URL: ${urlMatch[0]}`); 219 | resolve(urlMatch[0]); 220 | return; 221 | } 222 | } 223 | } catch (error) { 224 | if (debug) console.log("DEBUG: stdout read error:", error); 225 | } 226 | }; 227 | 228 | // Read from stderr 229 | const stderrReader = proc.stderr.getReader(); 230 | const readStderr = async () => { 231 | try { 232 | while (true) { 233 | const { done, value } = await stderrReader.read(); 234 | if (done) break; 235 | 236 | const chunk = new TextDecoder().decode(value); 237 | output += chunk; 238 | 239 | if (debug) console.log("DEBUG: stderr chunk:", chunk); 240 | 241 | // Look for the tunnel URL in the output 242 | const urlMatch = output.match( 243 | /https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/ 244 | ); 245 | if (urlMatch && !urlFound) { 246 | urlFound = true; 247 | if (debug) console.log(`DEBUG: Found tunnel URL: ${urlMatch[0]}`); 248 | resolve(urlMatch[0]); 249 | return; 250 | } 251 | } 252 | } catch (error) { 253 | if (debug) console.log("DEBUG: stderr read error:", error); 254 | } 255 | }; 256 | 257 | // Start reading from both streams 258 | readStdout(); 259 | readStderr(); 260 | 261 | // Set a timeout to avoid hanging forever 262 | setTimeout(() => { 263 | if (!urlFound) { 264 | console.error("Timeout waiting for CloudFlare tunnel URL"); 265 | if (debug) console.log("DEBUG: Full output so far:", output); 266 | proc.kill(); 267 | resolve(null); 268 | } 269 | }, 30000); // 30 second timeout 270 | 271 | // Handle process exit 272 | proc.exited.then((exitCode) => { 273 | if (!urlFound) { 274 | if (debug) 275 | console.log(`DEBUG: cloudflared exited with code: ${exitCode}`); 276 | console.error("CloudFlare tunnel process exited unexpectedly"); 277 | if (debug) console.log("DEBUG: Full output:", output); 278 | resolve(null); 279 | } 280 | }); 281 | }); 282 | } catch (error) { 283 | console.error("Failed to start CloudFlare tunnel"); 284 | if (debug) console.log("DEBUG: Error:", error); 285 | return null; 286 | } 287 | } 288 | 289 | async function setupCloudFlareTunnel( 290 | port: number, 291 | fileName: string, 292 | urlPath: string, 293 | debug: boolean, 294 | autoTunnel: boolean 295 | ) { 296 | let wantsTunnel = autoTunnel; 297 | 298 | if (!autoTunnel) wantsTunnel = await askCloudFlareTunnel(); 299 | 300 | if (!wantsTunnel) { 301 | console.log( 302 | `\n${DKGRAY}To make this file accessible outside your network, set up a port forward pointing ${CYAN}${port}${RESET}${DKGRAY} to your machine's IP address.${RESET}\n` 303 | ); 304 | return; 305 | } 306 | 307 | const isInstalled = await checkCloudFlaredInstalled(debug); 308 | if (!isInstalled) { 309 | showInstallationInstructions(); 310 | process.exit(1); 311 | } 312 | 313 | console.log("Starting CloudFlare tunnel..."); 314 | const tunnelUrl = await runCloudFlaredTunnel(port, debug); 315 | 316 | if (tunnelUrl) { 317 | console.log(` 318 | ${GREEN}Your file is now accessible at:\n\n${CYAN}${tunnelUrl}/${urlPath}${RESET} 319 | 320 | ${DKGRAY}Share this URL with others to let them download your file!${RESET} 321 | `); 322 | } 323 | } 324 | 325 | // --- Basic in-memory connection log --- 326 | const clientMap = new Map(); 327 | 328 | function logRequest(ip: string, ua: string) { 329 | const key = `${ip}::${ua}`; 330 | const existing = clientMap.get(key); 331 | const info: ClientInfo = existing 332 | ? { ...existing, count: existing.count + 1 } 333 | : { ip, ua, count: 1 }; 334 | 335 | clientMap.set(key, info); 336 | 337 | const shortUA = ua.slice(0, 60).replace(/\s+/g, " "); 338 | console.log( 339 | `[${new Date().toISOString()}] ${ip.toString()} (${shortUA}) → connection #${ 340 | info.count 341 | }` 342 | ); 343 | } 344 | 345 | // --- Server --- 346 | const server = Bun.serve({ 347 | port, 348 | async fetch(req) { 349 | const url = new URL(req.url); 350 | const ip = server.requestIP(req)?.toString() || "unknown"; 351 | const ua = req.headers.get("user-agent") || "unknown"; 352 | 353 | logRequest(ip, ua); 354 | 355 | // Download route for actual file 356 | if (url.pathname === `/download/${urlPath}`) { 357 | const file = Bun.file(filePath); 358 | return new Response(file, { 359 | headers: { 360 | "Content-Type": "application/octet-stream", 361 | "Content-Disposition": `attachment; filename="${fileName}"`, 362 | "Content-Length": fileSize.toString(), 363 | }, 364 | }); 365 | } 366 | 367 | // Main page with download interface 368 | if (url.pathname === `/${urlPath}`) { 369 | const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2); 370 | const html = ` 371 | 372 | 373 | 374 | 375 | 376 | Download ${fileName} 377 | 468 | 469 | 470 |
471 |
📁
472 |
${fileName}
473 |
${fileSizeMB} MB
474 | Download File 475 | 478 |
479 | 480 | `; 481 | 482 | return new Response(html, { 483 | headers: { 484 | "Content-Type": "text/html", 485 | }, 486 | }); 487 | } 488 | 489 | return new Response( 490 | "Hi there! Looks like you landed on the main page.\nTo get your file, just make sure to use the special link we gave you.\n", 491 | { status: 404 } 492 | ); 493 | }, 494 | }); 495 | 496 | console.log( 497 | `\n${GREEN}Serving ${fileName} on ${CYAN}http://localhost:${port}/${urlPath}${RESET}\n` 498 | ); 499 | 500 | // Start CloudFlare tunnel onboarding 501 | setupCloudFlareTunnel(port, fileName, urlPath, debug, autoTunnel).catch( 502 | console.error 503 | ); 504 | --------------------------------------------------------------------------------