Skip to content

Commit c4f3f7f

Browse files
committed
Add CLI support for static file uploads in package.json and update README with new deployment instructions. Introduced a new upload script and modified existing scripts for improved usability and clarity.
1 parent 94e9a36 commit c4f3f7f

3 files changed

Lines changed: 279 additions & 92 deletions

File tree

README.md

Lines changed: 15 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -66,106 +66,30 @@ export const { generateUploadUrl, recordAsset, gcOldAssets, listAssets } =
6666
exposeUploadApi(components.selfStaticHosting);
6767
```
6868

69-
### 3. Create upload script
70-
71-
Create `scripts/upload-static.ts`:
72-
73-
```ts
74-
import { readFileSync, readdirSync, existsSync } from "fs";
75-
import { join, relative, dirname, extname } from "path";
76-
import { randomUUID } from "crypto";
77-
import { fileURLToPath } from "url";
78-
import { execSync } from "child_process";
79-
80-
const __dirname = dirname(fileURLToPath(import.meta.url));
81-
const distDir = join(__dirname, "../dist");
82-
83-
const MIME_TYPES: Record<string, string> = {
84-
".html": "text/html; charset=utf-8",
85-
".js": "application/javascript; charset=utf-8",
86-
".css": "text/css; charset=utf-8",
87-
".json": "application/json; charset=utf-8",
88-
".png": "image/png",
89-
".svg": "image/svg+xml",
90-
".ico": "image/x-icon",
91-
".woff2": "font/woff2",
92-
};
93-
94-
function getMimeType(path: string): string {
95-
return MIME_TYPES[extname(path).toLowerCase()] || "application/octet-stream";
96-
}
97-
98-
function convexRun(fn: string, args: Record<string, unknown> = {}): string {
99-
const cmd = `npx convex run "${fn}" '${JSON.stringify(args)}' --typecheck=disable --codegen=disable`;
100-
return execSync(cmd, { encoding: "utf-8" }).trim();
101-
}
102-
103-
function collectFiles(dir: string, baseDir: string) {
104-
const files: Array<{ path: string; localPath: string; contentType: string }> = [];
105-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
106-
const fullPath = join(dir, entry.name);
107-
if (entry.isDirectory()) {
108-
files.push(...collectFiles(fullPath, baseDir));
109-
} else if (entry.isFile()) {
110-
files.push({
111-
path: "/" + relative(baseDir, fullPath).replace(/\\/g, "/"),
112-
localPath: fullPath,
113-
contentType: getMimeType(fullPath),
114-
});
115-
}
116-
}
117-
return files;
118-
}
119-
120-
async function main() {
121-
if (!existsSync(distDir)) {
122-
console.error("dist/ not found. Run 'npm run build' first.");
123-
process.exit(1);
124-
}
125-
126-
const deploymentId = randomUUID();
127-
const files = collectFiles(distDir, distDir);
128-
129-
console.log(`Uploading ${files.length} files...`);
130-
131-
for (const file of files) {
132-
const uploadUrl = JSON.parse(convexRun("staticHosting:generateUploadUrl"));
133-
const response = await fetch(uploadUrl, {
134-
method: "POST",
135-
headers: { "Content-Type": file.contentType },
136-
body: readFileSync(file.localPath),
137-
});
138-
const { storageId } = await response.json();
139-
140-
convexRun("staticHosting:recordAsset", {
141-
path: file.path,
142-
storageId,
143-
contentType: file.contentType,
144-
deploymentId,
145-
});
146-
console.log(` ✓ ${file.path}`);
147-
}
148-
149-
const deleted = JSON.parse(convexRun("staticHosting:gcOldAssets", { currentDeploymentId: deploymentId }));
150-
console.log(`Cleaned up ${deleted} old files`);
151-
}
152-
153-
main().catch(console.error);
154-
```
155-
156-
### 4. Add scripts to package.json
69+
### 3. Add deploy script to package.json
15770

15871
```json
15972
{
16073
"scripts": {
16174
"build": "vite build",
162-
"upload:static": "npx tsx scripts/upload-static.ts",
163-
"deploy:static": "npm run build && npm run upload:static"
75+
"deploy:static": "npm run build && npx @get-convex/self-static-hosting upload"
16476
}
16577
}
16678
```
16779

168-
### 5. Update your app's entry point (optional)
80+
The CLI will automatically find your `dist/` directory and upload to the `staticHosting` module.
81+
82+
**CLI Options:**
83+
```bash
84+
npx @get-convex/self-static-hosting upload [options]
85+
86+
Options:
87+
-d, --dist <path> Path to dist directory (default: ./dist)
88+
-m, --module <name> Convex module name (default: staticHosting)
89+
-h, --help Show help
90+
```
91+
92+
### 4. Update your app's entry point (optional)
16993

17094
In your `main.tsx`, use the helper to auto-detect the Convex URL when deployed:
17195

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@
1313
"component"
1414
],
1515
"type": "module",
16+
"bin": {
17+
"self-static-hosting": "./dist/cli/upload.js"
18+
},
1619
"scripts": {
1720
"dev": "run-p -r 'dev:*'",
1821
"dev:backend": "convex dev --typecheck-components",
1922
"dev:frontend": "cd example && vite --clearScreen false",
2023
"build:example": "cd example && vite build",
21-
"upload:static": "cd example && npx tsx scripts/upload-static.ts",
24+
"upload:static": "node dist/cli/upload.js --dist ./example/dist --module example",
2225
"deploy:static": "npm run build:example && npm run upload:static",
2326
"dev:build": "chokidar 'tsconfig*.json' 'src/**/*.ts' -i '**/*.test.ts' -c 'npm run build:codegen' --initial",
2427
"predev": "path-exists .env.local dist || (npm run build && convex dev --once)",

src/cli/upload.ts

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
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

Comments
 (0)