|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * CLI tool to upload static files to Convex storage. |
| 4 | + * |
| 5 | + * Usage: |
| 6 | + * npx @get-convex/self-static-hosting upload [options] |
| 7 | + * |
| 8 | + * Options: |
| 9 | + * --dist <path> Path to dist directory (default: ./dist) |
| 10 | + * --module <name> Convex module with upload functions (default: staticHosting) |
| 11 | + * --help Show help |
| 12 | + */ |
| 13 | + |
| 14 | +import { readFileSync, readdirSync, existsSync } from "fs"; |
| 15 | +import { join, relative, extname, resolve } from "path"; |
| 16 | +import { randomUUID } from "crypto"; |
| 17 | +import { execSync } from "child_process"; |
| 18 | + |
| 19 | +// MIME type mapping |
| 20 | +const MIME_TYPES: Record<string, string> = { |
| 21 | + ".html": "text/html; charset=utf-8", |
| 22 | + ".js": "application/javascript; charset=utf-8", |
| 23 | + ".mjs": "application/javascript; charset=utf-8", |
| 24 | + ".css": "text/css; charset=utf-8", |
| 25 | + ".json": "application/json; charset=utf-8", |
| 26 | + ".png": "image/png", |
| 27 | + ".jpg": "image/jpeg", |
| 28 | + ".jpeg": "image/jpeg", |
| 29 | + ".gif": "image/gif", |
| 30 | + ".svg": "image/svg+xml", |
| 31 | + ".ico": "image/x-icon", |
| 32 | + ".webp": "image/webp", |
| 33 | + ".woff": "font/woff", |
| 34 | + ".woff2": "font/woff2", |
| 35 | + ".ttf": "font/ttf", |
| 36 | + ".txt": "text/plain; charset=utf-8", |
| 37 | + ".map": "application/json", |
| 38 | + ".webmanifest": "application/manifest+json", |
| 39 | + ".xml": "application/xml", |
| 40 | +}; |
| 41 | + |
| 42 | +function getMimeType(path: string): string { |
| 43 | + return MIME_TYPES[extname(path).toLowerCase()] || "application/octet-stream"; |
| 44 | +} |
| 45 | + |
| 46 | +function parseArgs(args: string[]): { |
| 47 | + dist: string; |
| 48 | + module: string; |
| 49 | + help: boolean; |
| 50 | +} { |
| 51 | + const result = { |
| 52 | + dist: "./dist", |
| 53 | + module: "staticHosting", |
| 54 | + help: false, |
| 55 | + }; |
| 56 | + |
| 57 | + for (let i = 0; i < args.length; i++) { |
| 58 | + const arg = args[i]; |
| 59 | + if (arg === "--help" || arg === "-h") { |
| 60 | + result.help = true; |
| 61 | + } else if (arg === "--dist" || arg === "-d") { |
| 62 | + result.dist = args[++i] || result.dist; |
| 63 | + } else if (arg === "--module" || arg === "-m") { |
| 64 | + result.module = args[++i] || result.module; |
| 65 | + } |
| 66 | + } |
| 67 | + |
| 68 | + return result; |
| 69 | +} |
| 70 | + |
| 71 | +function showHelp(): void { |
| 72 | + console.log(` |
| 73 | +Usage: npx @get-convex/self-static-hosting upload [options] |
| 74 | +
|
| 75 | +Upload static files from a dist directory to Convex storage. |
| 76 | +
|
| 77 | +Options: |
| 78 | + -d, --dist <path> Path to dist directory (default: ./dist) |
| 79 | + -m, --module <name> Convex module with upload functions (default: staticHosting) |
| 80 | + -h, --help Show this help message |
| 81 | +
|
| 82 | +Examples: |
| 83 | + npx @get-convex/self-static-hosting upload |
| 84 | + npx @get-convex/self-static-hosting upload --dist ./build |
| 85 | + npx @get-convex/self-static-hosting upload --module myStaticHosting |
| 86 | +
|
| 87 | +Setup: |
| 88 | + 1. Create a Convex module that exposes the upload API: |
| 89 | +
|
| 90 | + // convex/staticHosting.ts |
| 91 | + import { exposeUploadApi } from "@get-convex/self-static-hosting"; |
| 92 | + import { components } from "./_generated/api"; |
| 93 | +
|
| 94 | + export const { generateUploadUrl, recordAsset, gcOldAssets, listAssets } = |
| 95 | + exposeUploadApi(components.selfStaticHosting); |
| 96 | +
|
| 97 | + 2. Run the upload command after building your app: |
| 98 | +
|
| 99 | + npm run build |
| 100 | + npx @get-convex/self-static-hosting upload |
| 101 | +`); |
| 102 | +} |
| 103 | + |
| 104 | +function convexRun( |
| 105 | + functionPath: string, |
| 106 | + args: Record<string, unknown> = {}, |
| 107 | +): string { |
| 108 | + const argsJson = JSON.stringify(args); |
| 109 | + const cmd = `npx convex run "${functionPath}" '${argsJson}' --typecheck=disable --codegen=disable`; |
| 110 | + try { |
| 111 | + const result = execSync(cmd, { |
| 112 | + encoding: "utf-8", |
| 113 | + stdio: ["pipe", "pipe", "pipe"], |
| 114 | + }); |
| 115 | + return result.trim(); |
| 116 | + } catch (error) { |
| 117 | + const execError = error as { stderr?: string; stdout?: string }; |
| 118 | + console.error("Convex run failed:", execError.stderr || execError.stdout); |
| 119 | + throw error; |
| 120 | + } |
| 121 | +} |
| 122 | + |
| 123 | +function collectFiles( |
| 124 | + dir: string, |
| 125 | + baseDir: string, |
| 126 | +): Array<{ path: string; localPath: string; contentType: string }> { |
| 127 | + const files: Array<{ |
| 128 | + path: string; |
| 129 | + localPath: string; |
| 130 | + contentType: string; |
| 131 | + }> = []; |
| 132 | + |
| 133 | + for (const entry of readdirSync(dir, { withFileTypes: true })) { |
| 134 | + const fullPath = join(dir, entry.name); |
| 135 | + if (entry.isDirectory()) { |
| 136 | + files.push(...collectFiles(fullPath, baseDir)); |
| 137 | + } else if (entry.isFile()) { |
| 138 | + files.push({ |
| 139 | + path: "/" + relative(baseDir, fullPath).replace(/\\/g, "/"), |
| 140 | + localPath: fullPath, |
| 141 | + contentType: getMimeType(fullPath), |
| 142 | + }); |
| 143 | + } |
| 144 | + } |
| 145 | + return files; |
| 146 | +} |
| 147 | + |
| 148 | +async function main(): Promise<void> { |
| 149 | + const args = parseArgs(process.argv.slice(2)); |
| 150 | + |
| 151 | + if (args.help) { |
| 152 | + showHelp(); |
| 153 | + process.exit(0); |
| 154 | + } |
| 155 | + |
| 156 | + const distDir = resolve(args.dist); |
| 157 | + const moduleName = args.module; |
| 158 | + |
| 159 | + if (!existsSync(distDir)) { |
| 160 | + console.error(`Error: dist directory not found: ${distDir}`); |
| 161 | + console.error("Run your build command first (e.g., 'npm run build')"); |
| 162 | + process.exit(1); |
| 163 | + } |
| 164 | + |
| 165 | + const deploymentId = randomUUID(); |
| 166 | + const files = collectFiles(distDir, distDir); |
| 167 | + |
| 168 | + console.log("🔒 Using secure internal functions (requires Convex CLI auth)"); |
| 169 | + console.log( |
| 170 | + `Uploading ${files.length} files with deployment ID: ${deploymentId}`, |
| 171 | + ); |
| 172 | + console.log(`Module: ${moduleName}`); |
| 173 | + console.log(""); |
| 174 | + |
| 175 | + for (const file of files) { |
| 176 | + const content = readFileSync(file.localPath); |
| 177 | + |
| 178 | + // Get upload URL via internal function |
| 179 | + const uploadUrlOutput = convexRun(`${moduleName}:generateUploadUrl`); |
| 180 | + const uploadUrl = JSON.parse(uploadUrlOutput); |
| 181 | + |
| 182 | + // Upload to storage |
| 183 | + const response = await fetch(uploadUrl, { |
| 184 | + method: "POST", |
| 185 | + headers: { "Content-Type": file.contentType }, |
| 186 | + body: content, |
| 187 | + }); |
| 188 | + |
| 189 | + const { storageId } = (await response.json()) as { storageId: string }; |
| 190 | + |
| 191 | + // Record in database via internal function |
| 192 | + convexRun(`${moduleName}:recordAsset`, { |
| 193 | + path: file.path, |
| 194 | + storageId, |
| 195 | + contentType: file.contentType, |
| 196 | + deploymentId, |
| 197 | + }); |
| 198 | + |
| 199 | + console.log(` ✓ ${file.path} (${file.contentType})`); |
| 200 | + } |
| 201 | + |
| 202 | + console.log(""); |
| 203 | + |
| 204 | + // Garbage collect old files |
| 205 | + const deletedOutput = convexRun(`${moduleName}:gcOldAssets`, { |
| 206 | + currentDeploymentId: deploymentId, |
| 207 | + }); |
| 208 | + const deleted = JSON.parse(deletedOutput); |
| 209 | + |
| 210 | + if (deleted > 0) { |
| 211 | + console.log(`Cleaned up ${deleted} old file(s) from previous deployments`); |
| 212 | + } |
| 213 | + |
| 214 | + // Optional: Purge Cloudflare cache if configured |
| 215 | + const cloudflareZoneId = process.env.CLOUDFLARE_ZONE_ID; |
| 216 | + const cloudflareApiToken = process.env.CLOUDFLARE_API_TOKEN; |
| 217 | + |
| 218 | + if (cloudflareZoneId && cloudflareApiToken) { |
| 219 | + console.log(""); |
| 220 | + console.log("☁️ Purging Cloudflare cache..."); |
| 221 | + try { |
| 222 | + convexRun(`${moduleName}:purgeCloudflareCache`, { |
| 223 | + zoneId: cloudflareZoneId, |
| 224 | + apiToken: cloudflareApiToken, |
| 225 | + purgeAll: true, |
| 226 | + }); |
| 227 | + console.log(" Cache purged successfully"); |
| 228 | + } catch { |
| 229 | + console.warn(" Warning: Cloudflare cache purge failed (function may not be exposed)"); |
| 230 | + } |
| 231 | + } |
| 232 | + |
| 233 | + console.log(""); |
| 234 | + console.log("✨ Upload complete!"); |
| 235 | + |
| 236 | + // Try to show the deployment URL |
| 237 | + if (existsSync(".env.local")) { |
| 238 | + const envContent = readFileSync(".env.local", "utf-8"); |
| 239 | + const match = envContent.match(/(?:VITE_)?CONVEX_URL=(.+)/); |
| 240 | + if (match) { |
| 241 | + const convexUrl = match[1].trim(); |
| 242 | + console.log(""); |
| 243 | + console.log( |
| 244 | + `Your app is now available at: ${convexUrl.replace(".convex.cloud", ".convex.site")}`, |
| 245 | + ); |
| 246 | + } |
| 247 | + } |
| 248 | + |
| 249 | + if (!cloudflareZoneId || !cloudflareApiToken) { |
| 250 | + console.log(""); |
| 251 | + console.log( |
| 252 | + "💡 Tip: Set CLOUDFLARE_ZONE_ID and CLOUDFLARE_API_TOKEN to enable CDN cache purging", |
| 253 | + ); |
| 254 | + } |
| 255 | +} |
| 256 | + |
| 257 | +main().catch((error) => { |
| 258 | + console.error("Upload failed:", error); |
| 259 | + process.exit(1); |
| 260 | +}); |
0 commit comments