@samebase/convex-hosting 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/INTEGRATION.md +399 -0
  3. package/LICENSE +201 -0
  4. package/NOTICE +7 -0
  5. package/README.md +470 -2
  6. package/dist/cli/args.d.ts +28 -0
  7. package/dist/cli/args.d.ts.map +1 -0
  8. package/dist/cli/args.js +148 -0
  9. package/dist/cli/args.js.map +1 -0
  10. package/dist/cli/argumentChunks.d.ts +3 -0
  11. package/dist/cli/argumentChunks.d.ts.map +1 -0
  12. package/dist/cli/argumentChunks.js +29 -0
  13. package/dist/cli/argumentChunks.js.map +1 -0
  14. package/dist/cli/commands.d.ts +8 -0
  15. package/dist/cli/commands.d.ts.map +1 -0
  16. package/dist/cli/commands.js +56 -0
  17. package/dist/cli/commands.js.map +1 -0
  18. package/dist/cli/componentName.d.ts +32 -0
  19. package/dist/cli/componentName.d.ts.map +1 -0
  20. package/dist/cli/componentName.js +43 -0
  21. package/dist/cli/componentName.js.map +1 -0
  22. package/dist/cli/deploy.d.ts +16 -0
  23. package/dist/cli/deploy.d.ts.map +1 -0
  24. package/dist/cli/deploy.js +283 -0
  25. package/dist/cli/deploy.js.map +1 -0
  26. package/dist/cli/deployEnvironment.d.ts +10 -0
  27. package/dist/cli/deployEnvironment.d.ts.map +1 -0
  28. package/dist/cli/deployEnvironment.js +12 -0
  29. package/dist/cli/deployEnvironment.js.map +1 -0
  30. package/dist/cli/index.d.ts +12 -0
  31. package/dist/cli/index.d.ts.map +1 -0
  32. package/dist/cli/index.js +60 -0
  33. package/dist/cli/index.js.map +1 -0
  34. package/dist/cli/upload.d.ts +22 -0
  35. package/dist/cli/upload.d.ts.map +1 -0
  36. package/dist/cli/upload.js +668 -0
  37. package/dist/cli/upload.js.map +1 -0
  38. package/dist/client/_generated/_ignore.d.ts +1 -0
  39. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  40. package/dist/client/_generated/_ignore.js +3 -0
  41. package/dist/client/_generated/_ignore.js.map +1 -0
  42. package/dist/client/index.d.ts +119 -0
  43. package/dist/client/index.d.ts.map +1 -0
  44. package/dist/client/index.js +272 -0
  45. package/dist/client/index.js.map +1 -0
  46. package/dist/component/_generated/api.d.ts +38 -0
  47. package/dist/component/_generated/api.d.ts.map +1 -0
  48. package/dist/component/_generated/api.js +31 -0
  49. package/dist/component/_generated/api.js.map +1 -0
  50. package/dist/component/_generated/component.d.ts +43 -0
  51. package/dist/component/_generated/component.d.ts.map +1 -0
  52. package/dist/component/_generated/component.js +11 -0
  53. package/dist/component/_generated/component.js.map +1 -0
  54. package/dist/component/_generated/dataModel.d.ts +46 -0
  55. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  56. package/dist/component/_generated/dataModel.js +11 -0
  57. package/dist/component/_generated/dataModel.js.map +1 -0
  58. package/dist/component/_generated/server.d.ts +121 -0
  59. package/dist/component/_generated/server.d.ts.map +1 -0
  60. package/dist/component/_generated/server.js +78 -0
  61. package/dist/component/_generated/server.js.map +1 -0
  62. package/dist/component/convex.config.d.ts +3 -0
  63. package/dist/component/convex.config.d.ts.map +1 -0
  64. package/dist/component/convex.config.js +3 -0
  65. package/dist/component/convex.config.js.map +1 -0
  66. package/dist/component/http.d.ts +4 -0
  67. package/dist/component/http.d.ts.map +1 -0
  68. package/dist/component/http.js +125 -0
  69. package/dist/component/http.js.map +1 -0
  70. package/dist/component/lib.d.ts +164 -0
  71. package/dist/component/lib.d.ts.map +1 -0
  72. package/dist/component/lib.js +768 -0
  73. package/dist/component/lib.js.map +1 -0
  74. package/dist/component/schema.d.ts +84 -0
  75. package/dist/component/schema.d.ts.map +1 -0
  76. package/dist/component/schema.js +69 -0
  77. package/dist/component/schema.js.map +1 -0
  78. package/dist/component/serving.d.ts +17 -0
  79. package/dist/component/serving.d.ts.map +1 -0
  80. package/dist/component/serving.js +150 -0
  81. package/dist/component/serving.js.map +1 -0
  82. package/dist/react/index.d.ts +59 -0
  83. package/dist/react/index.d.ts.map +1 -0
  84. package/dist/react/index.js +137 -0
  85. package/dist/react/index.js.map +1 -0
  86. package/package.json +78 -3
  87. package/src/cli/args.test.ts +79 -0
  88. package/src/cli/args.ts +153 -0
  89. package/src/cli/argumentChunks.test.ts +27 -0
  90. package/src/cli/argumentChunks.ts +40 -0
  91. package/src/cli/commands.ts +68 -0
  92. package/src/cli/componentName.test.ts +41 -0
  93. package/src/cli/componentName.ts +46 -0
  94. package/src/cli/deploy.ts +336 -0
  95. package/src/cli/deployEnvironment.test.ts +27 -0
  96. package/src/cli/deployEnvironment.ts +17 -0
  97. package/src/cli/index.ts +65 -0
  98. package/src/cli/upload.ts +885 -0
  99. package/src/client/_generated/_ignore.ts +1 -0
  100. package/src/client/index.test.ts +510 -0
  101. package/src/client/index.ts +355 -0
  102. package/src/client/setup.test.ts +22 -0
  103. package/src/component/_generated/api.ts +54 -0
  104. package/src/component/_generated/component.ts +55 -0
  105. package/src/component/_generated/dataModel.ts +60 -0
  106. package/src/component/_generated/server.ts +156 -0
  107. package/src/component/convex.config.ts +3 -0
  108. package/src/component/http.test.ts +223 -0
  109. package/src/component/http.ts +144 -0
  110. package/src/component/lib.test.ts +1007 -0
  111. package/src/component/lib.ts +862 -0
  112. package/src/component/schema.ts +75 -0
  113. package/src/component/serving.ts +158 -0
  114. package/src/component/setup.test.ts +11 -0
  115. package/src/react/index.tsx +197 -0
  116. package/src/test.ts +23 -0
@@ -0,0 +1,668 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI tool to upload static files to a Convex convex-hosting component.
4
+ *
5
+ * Usage:
6
+ * npx @samebase/convex-hosting upload [options]
7
+ *
8
+ * Options:
9
+ * --dist <path> Path to dist directory (default: ./dist)
10
+ * --component <name> Component instance name (default: hosting)
11
+ * --prod Deploy to production deployment
12
+ * --preview-name <name> Upload to the named preview deployment
13
+ * --build Run the frontend build before uploading
14
+ * --build-command <cmd> Override the frontend build command
15
+ * --spa / --no-spa Enable or disable SPA fallback
16
+ * --cdn Use the legacy convex-fs integration
17
+ * --cdn-delete-function App function that deletes old CDN blobs
18
+ * --concurrency <n> Parallel upload workers (default: 5)
19
+ * --help Show help
20
+ */
21
+ import { readFileSync, readdirSync, existsSync } from "fs";
22
+ import { join, relative, extname, resolve } from "path";
23
+ import { randomUUID } from "crypto";
24
+ import { runConvexAsync, spawnShell } from "./commands.js";
25
+ import { chunkBySerializedArgument, MAX_CONVEX_ARGUMENT_BYTES } from "./argumentChunks.js";
26
+ import { parseUploadArgs } from "./args.js";
27
+ import { componentNameCandidates, isLegacyAutoDetected, legacyComponentNameWarning, } from "./componentName.js";
28
+ // MIME type mapping
29
+ const MIME_TYPES = {
30
+ ".html": "text/html; charset=utf-8",
31
+ ".js": "application/javascript; charset=utf-8",
32
+ ".mjs": "application/javascript; charset=utf-8",
33
+ ".css": "text/css; charset=utf-8",
34
+ ".json": "application/json; charset=utf-8",
35
+ ".png": "image/png",
36
+ ".jpg": "image/jpeg",
37
+ ".jpeg": "image/jpeg",
38
+ ".gif": "image/gif",
39
+ ".svg": "image/svg+xml",
40
+ ".ico": "image/x-icon",
41
+ ".webp": "image/webp",
42
+ ".woff": "font/woff",
43
+ ".woff2": "font/woff2",
44
+ ".ttf": "font/ttf",
45
+ ".txt": "text/plain; charset=utf-8",
46
+ ".map": "application/json",
47
+ ".webmanifest": "application/manifest+json",
48
+ ".xml": "application/xml",
49
+ };
50
+ function getMimeType(path) {
51
+ return MIME_TYPES[extname(path).toLowerCase()] || "application/octet-stream";
52
+ }
53
+ function showHelp() {
54
+ console.log(`
55
+ Usage: npx @samebase/convex-hosting upload [options]
56
+
57
+ Upload static files from a dist directory to Convex storage.
58
+
59
+ Options:
60
+ -d, --dist <path> Path to dist directory (default: ./dist)
61
+ -c, --component <name> Static-hosting component instance name (default: hosting)
62
+ --prod Deploy to production deployment
63
+ --preview-name <name> Upload to the named preview deployment
64
+ -b, --build Run the build command with VITE_CONVEX_URL +
65
+ CONVEX_HOSTING_BASE_PATH set before uploading
66
+ --build-command <cmd> Build command to run (default: 'npm run build').
67
+ Implies --build.
68
+ --no-spa Disable SPA fallback for this deployment (return a
69
+ 404 instead of falling back to /index.html)
70
+ --spa Enable SPA fallback for this deployment (default)
71
+ --cdn Use the legacy convex-fs integration
72
+ --cdn-delete-function <name> Legacy app function that deletes CDN blobs
73
+ -j, --concurrency <n> Number of parallel uploads (default: 5)
74
+ -h, --help Show this help message
75
+
76
+ Examples:
77
+ # Upload to Convex storage
78
+ npx @samebase/convex-hosting upload
79
+ npx @samebase/convex-hosting upload --dist ./build --prod
80
+ npx @samebase/convex-hosting upload --build --prod
81
+
82
+ # Upload through an existing legacy convex-fs integration
83
+ npx @samebase/convex-hosting upload --cdn --prod
84
+ `);
85
+ }
86
+ // Global flag for production mode
87
+ let useProd = true;
88
+ let previewName = null;
89
+ const MAX_ASSETS_PER_DEPLOYMENT = 1800;
90
+ const MAX_MANIFEST_SERIALIZED_BYTES = 2 * 1024 * 1024;
91
+ // A Convex CLI function result can be truncated around 64 KiB. Signed upload
92
+ // URLs are long enough that 250 sometimes crosses that boundary.
93
+ const UPLOAD_URL_BATCH_SIZE = 100;
94
+ const MAINTENANCE_PAGE_SIZE = 256;
95
+ const MAX_MAINTENANCE_PAGES = 20;
96
+ /**
97
+ * Resolve the component's deployment URLs and the instance name that answered.
98
+ *
99
+ * When the caller relied on the default name we also probe the
100
+ * @convex-dev/static-hosting name, so an app that kept that instance works without
101
+ * `--component staticHosting`
102
+ * on every command; landing on the legacy name prints a one-time warning. Bails
103
+ * the CLI if no candidate is deployed. Uploading wouldn't work either, so a
104
+ * fallback would only hide the real problem.
105
+ */
106
+ async function fetchUrls(requested) {
107
+ const candidates = componentNameCandidates(requested);
108
+ for (const name of candidates) {
109
+ try {
110
+ const out = await convexRunAsync(name, "lib:getUrls", {}, { quiet: true });
111
+ const urls = JSON.parse(out);
112
+ if (isLegacyAutoDetected(requested, name)) {
113
+ console.warn(legacyComponentNameWarning(name));
114
+ }
115
+ return { urls, componentName: name };
116
+ }
117
+ catch {
118
+ // Try the next candidate name.
119
+ }
120
+ }
121
+ console.error(`Could not reach component ${candidates
122
+ .map((name) => `"${name}"`)
123
+ .join(" or ")}. Deploy the Convex backend first and ensure --component ` +
124
+ `matches the name in convex.config.ts.`);
125
+ process.exit(1);
126
+ }
127
+ function convexRunAsync(componentName, functionPath, args = {}, options = {}) {
128
+ const serializedArgs = JSON.stringify(args);
129
+ const argumentBytes = Buffer.byteLength(serializedArgs);
130
+ if (argumentBytes > MAX_CONVEX_ARGUMENT_BYTES) {
131
+ throw new Error("The Convex function argument is " +
132
+ argumentBytes +
133
+ " bytes, which exceeds the portable command-line limit. " +
134
+ "This is an internal chunking error; please report it.");
135
+ }
136
+ return runConvexAsync([
137
+ "run",
138
+ ...(componentName ? ["--component", componentName] : []),
139
+ functionPath,
140
+ serializedArgs,
141
+ "--typecheck=disable",
142
+ "--codegen=disable",
143
+ ...(useProd ? ["--prod"] : []),
144
+ ...(previewName === null ? [] : ["--preview-name", previewName]),
145
+ ], options);
146
+ }
147
+ function errorMessage(error) {
148
+ return error instanceof Error ? error.message : String(error);
149
+ }
150
+ async function cleanUpPendingCdnBlobs(componentName, cdnDeleteFunction) {
151
+ if (!cdnDeleteFunction)
152
+ return 0;
153
+ let cleaned = 0;
154
+ for (let page = 0; page < MAX_MAINTENANCE_PAGES; page++) {
155
+ const output = await convexRunAsync(componentName, "lib:listPendingBlobCleanup", {
156
+ limit: 250,
157
+ });
158
+ const blobIds = JSON.parse(output);
159
+ if (!Array.isArray(blobIds) || !blobIds.every((blobId) => typeof blobId === "string")) {
160
+ throw new Error("Component returned invalid pending CDN cleanup data");
161
+ }
162
+ if (blobIds.length === 0)
163
+ return cleaned;
164
+ const deleteChunks = chunkBySerializedArgument(blobIds, (chunk) => ({
165
+ blobIds: chunk,
166
+ }));
167
+ for (const chunk of deleteChunks) {
168
+ await convexRunAsync(undefined, cdnDeleteFunction, { blobIds: chunk });
169
+ await convexRunAsync(componentName, "lib:acknowledgeBlobCleanup", {
170
+ blobIds: chunk,
171
+ });
172
+ cleaned += chunk.length;
173
+ }
174
+ }
175
+ console.warn("Warning: CDN cleanup reached its per-run maintenance limit; a later upload will continue it.");
176
+ return cleaned;
177
+ }
178
+ async function cleanUpAbandonedUploads(componentName, cdnDeleteFunction) {
179
+ let discarded = 0;
180
+ let deletedFiles = 0;
181
+ let queuedBlobIds = 0;
182
+ try {
183
+ let stagingDrained = false;
184
+ for (let page = 0; page < MAX_MAINTENANCE_PAGES; page++) {
185
+ const output = await convexRunAsync(componentName, "lib:cleanupAbandonedStaging", {
186
+ limit: MAINTENANCE_PAGE_SIZE,
187
+ });
188
+ const result = JSON.parse(output);
189
+ if (typeof result.discarded !== "number" ||
190
+ typeof result.deletedFiles !== "number" ||
191
+ typeof result.queuedBlobIds !== "number") {
192
+ throw new Error("Component returned invalid staging cleanup data");
193
+ }
194
+ discarded += result.discarded;
195
+ deletedFiles += result.deletedFiles;
196
+ queuedBlobIds += result.queuedBlobIds;
197
+ if (result.discarded < MAINTENANCE_PAGE_SIZE) {
198
+ stagingDrained = true;
199
+ break;
200
+ }
201
+ }
202
+ if (!stagingDrained) {
203
+ console.warn("Warning: Staging cleanup reached its per-run maintenance limit; a later upload will continue it.");
204
+ }
205
+ deletedFiles += await cleanUpPendingStorage(componentName);
206
+ deletedFiles += await cleanUpUnreferencedStorage(componentName, undefined, "desc");
207
+ deletedFiles += await cleanUpUnreferencedStorage(componentName, undefined, "asc");
208
+ if (discarded > 0 || deletedFiles > 0 || queuedBlobIds > 0) {
209
+ console.log(`Recovered ${discarded} abandoned manifest record(s), removed ${deletedFiles} old unreferenced file(s), and queued ${queuedBlobIds} CDN blob(s) for cleanup.`);
210
+ }
211
+ if (cdnDeleteFunction) {
212
+ const cleaned = await cleanUpPendingCdnBlobs(componentName, cdnDeleteFunction);
213
+ if (cleaned > 0) {
214
+ console.log(`Cleaned up ${cleaned} pending CDN blob(s).`);
215
+ }
216
+ }
217
+ }
218
+ catch (cleanupError) {
219
+ console.warn("Warning: Could not finish abandoned-upload maintenance: " + errorMessage(cleanupError));
220
+ }
221
+ }
222
+ async function cleanUpUnreferencedStorage(componentName, before, order = "asc") {
223
+ let deleted = 0;
224
+ for (let page = 0; page < MAX_MAINTENANCE_PAGES; page++) {
225
+ const output = await convexRunAsync(componentName, "lib:cleanupUnreferencedStorage", {
226
+ limit: MAINTENANCE_PAGE_SIZE,
227
+ order,
228
+ ...(before === undefined ? {} : { before }),
229
+ });
230
+ const result = JSON.parse(output);
231
+ if (typeof result.deleted !== "number" || typeof result.needsAnotherPass !== "boolean") {
232
+ throw new Error("Component returned invalid storage cleanup data");
233
+ }
234
+ deleted += result.deleted;
235
+ if (!result.needsAnotherPass)
236
+ return deleted;
237
+ }
238
+ console.warn("Warning: Orphan storage cleanup reached its per-run maintenance limit; a later upload will continue it.");
239
+ return deleted;
240
+ }
241
+ async function cleanUpPendingStorage(componentName) {
242
+ let deleted = 0;
243
+ for (let page = 0; page < MAX_MAINTENANCE_PAGES; page++) {
244
+ const output = await convexRunAsync(componentName, "lib:cleanupPendingStorage", {
245
+ limit: MAINTENANCE_PAGE_SIZE,
246
+ });
247
+ const result = JSON.parse(output);
248
+ if (typeof result.deleted !== "number" || typeof result.needsAnotherPass !== "boolean") {
249
+ throw new Error("Component returned invalid pending-storage cleanup data");
250
+ }
251
+ deleted += result.deleted;
252
+ if (!result.needsAnotherPass)
253
+ return deleted;
254
+ }
255
+ console.warn("Warning: Published storage cleanup reached its per-run maintenance limit; a later upload will continue it.");
256
+ return deleted;
257
+ }
258
+ async function forEachWithConcurrency(items, concurrency, worker) {
259
+ let nextIndex = 0;
260
+ let firstError;
261
+ async function runWorker() {
262
+ while (firstError === undefined) {
263
+ const index = nextIndex++;
264
+ if (index >= items.length)
265
+ return;
266
+ try {
267
+ await worker(items[index], index);
268
+ }
269
+ catch (error) {
270
+ firstError ??= error;
271
+ }
272
+ }
273
+ }
274
+ const workerCount = Math.min(concurrency, items.length);
275
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
276
+ if (firstError !== undefined)
277
+ throw firstError;
278
+ }
279
+ async function cleanUpFailedUpload(componentName, deploymentId, uploaded, cdnDeleteFunction, publishAttempted) {
280
+ let discardSucceeded = false;
281
+ let discarded = 0;
282
+ try {
283
+ const output = await convexRunAsync(componentName, "lib:discardStagedDeployment", {
284
+ deploymentId,
285
+ });
286
+ const result = JSON.parse(output);
287
+ if (typeof result.discarded !== "number") {
288
+ throw new Error("Component returned an invalid discard result");
289
+ }
290
+ discardSucceeded = true;
291
+ discarded = result.discarded;
292
+ }
293
+ catch (cleanupError) {
294
+ console.warn("Warning: Could not discard the staged manifest: " + errorMessage(cleanupError));
295
+ }
296
+ // Convex mutations have a single transaction order. If discard succeeds,
297
+ // either it removed the staged rows before a late publish could use them, or
298
+ // publish already committed and removed them first. In the latter case the
299
+ // component cleanup below sees the live references and keeps those files.
300
+ const canCleanComponentFiles = !publishAttempted || discardSucceeded;
301
+ if (uploaded.storageIds.length > 0 && canCleanComponentFiles) {
302
+ try {
303
+ let deleted = 0;
304
+ let stillReferenced = 0;
305
+ const chunks = chunkBySerializedArgument(uploaded.storageIds, (storageIds) => ({
306
+ storageIds,
307
+ }));
308
+ for (const storageIds of chunks) {
309
+ const output = await convexRunAsync(componentName, "lib:deleteUploadedFiles", {
310
+ storageIds,
311
+ });
312
+ const result = JSON.parse(output);
313
+ if (typeof result.deleted === "number")
314
+ deleted += result.deleted;
315
+ if (typeof result.stillReferenced === "number") {
316
+ stillReferenced += result.stillReferenced;
317
+ }
318
+ }
319
+ console.log(`Removed ${deleted} file(s) from the failed upload.`);
320
+ if (stillReferenced > 0) {
321
+ console.warn(`Kept ${stillReferenced} file(s) because the live deployment references them. The publish may have completed even though its response was lost.`);
322
+ }
323
+ }
324
+ catch (cleanupError) {
325
+ console.warn("Warning: Could not remove component files from the failed upload: " +
326
+ errorMessage(cleanupError));
327
+ }
328
+ }
329
+ else if (uploaded.storageIds.length > 0) {
330
+ console.warn(`${uploaded.storageIds.length} component file(s) were left in place because the CLI could not cancel or confirm the publish.`);
331
+ }
332
+ // The app-level CDN delete function cannot inspect component-private live
333
+ // references. Only delete CDN blobs when no publish was attempted, or when
334
+ // discard removed staged rows and therefore won the transaction race.
335
+ const canDeleteCdnBlobs = !publishAttempted || (discardSucceeded && discarded > 0);
336
+ if (uploaded.blobIds.length > 0 && canDeleteCdnBlobs) {
337
+ try {
338
+ // Record these IDs before calling app-owned deletion. If that call fails
339
+ // or only partially completes, the next upload can retry safely.
340
+ const chunks = chunkBySerializedArgument(uploaded.blobIds, (blobIds) => ({
341
+ blobIds,
342
+ }));
343
+ for (const blobIds of chunks) {
344
+ await convexRunAsync(componentName, "lib:queueBlobCleanup", {
345
+ blobIds,
346
+ });
347
+ }
348
+ if (cdnDeleteFunction) {
349
+ const cleaned = await cleanUpPendingCdnBlobs(componentName, cdnDeleteFunction);
350
+ console.log(`Removed ${cleaned} pending CDN blob(s).`);
351
+ }
352
+ else {
353
+ console.warn(`${uploaded.blobIds.length} CDN blob(s) from the failed upload were queued for cleanup. Pass --cdn-delete-function on a later upload to delete them.`);
354
+ }
355
+ }
356
+ catch (cleanupError) {
357
+ console.warn("Warning: Could not finish queuing or deleting CDN blobs from the failed upload: " +
358
+ errorMessage(cleanupError));
359
+ }
360
+ }
361
+ else if (uploaded.blobIds.length > 0 && !canDeleteCdnBlobs) {
362
+ console.warn(`${uploaded.blobIds.length} CDN blob(s) were left in place because the CLI could not determine whether the publish completed.`);
363
+ }
364
+ }
365
+ async function uploadWithConcurrency(files, componentName, deploymentId, useCdn, cdnUploadBase, concurrency, spaFallback, cdnDeleteFunction) {
366
+ const total = files.length;
367
+ const uploaded = { storageIds: [], blobIds: [] };
368
+ // Separate CDN and storage files
369
+ const cdnFiles = [];
370
+ const storageFiles = [];
371
+ for (const file of files) {
372
+ const isHtml = file.contentType.startsWith("text/html");
373
+ if (useCdn && !isHtml && cdnUploadBase) {
374
+ cdnFiles.push(file);
375
+ }
376
+ else {
377
+ storageFiles.push(file);
378
+ }
379
+ }
380
+ // Upload storage files using batch operations
381
+ let completed = 0;
382
+ const allAssets = [];
383
+ let publishAttempted = false;
384
+ try {
385
+ if (storageFiles.length > 0) {
386
+ // Keep both the Convex array value and the child-process stdout buffer
387
+ // bounded, even for a deployment with thousands of files.
388
+ console.log(` Generating ${storageFiles.length} upload URLs...`);
389
+ const uploadUrls = [];
390
+ for (let offset = 0; offset < storageFiles.length; offset += UPLOAD_URL_BATCH_SIZE) {
391
+ const count = Math.min(UPLOAD_URL_BATCH_SIZE, storageFiles.length - offset);
392
+ const urlsOutput = await convexRunAsync(componentName, "lib:generateUploadUrls", { count });
393
+ const batch = JSON.parse(urlsOutput);
394
+ if (!Array.isArray(batch) ||
395
+ batch.length !== count ||
396
+ !batch.every((url) => typeof url === "string")) {
397
+ throw new Error("Component returned invalid upload URLs");
398
+ }
399
+ uploadUrls.push(...batch);
400
+ }
401
+ // Step 2: Upload all files in parallel via fetch
402
+ const storageIds = new Array(storageFiles.length);
403
+ await forEachWithConcurrency(storageFiles, concurrency, async (file, idx) => {
404
+ const content = readFileSync(file.localPath);
405
+ const response = await fetch(uploadUrls[idx], {
406
+ method: "POST",
407
+ headers: { "Content-Type": file.contentType },
408
+ body: content,
409
+ });
410
+ if (!response.ok) {
411
+ throw new Error("Storage upload failed for " +
412
+ file.path +
413
+ ": " +
414
+ response.status +
415
+ " " +
416
+ response.statusText);
417
+ }
418
+ const { storageId } = (await response.json());
419
+ if (typeof storageId !== "string") {
420
+ throw new Error("Storage upload returned no storageId for " + file.path);
421
+ }
422
+ storageIds[idx] = storageId;
423
+ uploaded.storageIds.push(storageId);
424
+ completed++;
425
+ const isHtml = file.contentType.startsWith("text/html");
426
+ console.log(` [${completed}/${total}] ${file.path} (${isHtml ? "storage/html" : "storage"})`);
427
+ });
428
+ for (let i = 0; i < storageFiles.length; i++) {
429
+ allAssets.push({
430
+ path: storageFiles[i].path,
431
+ storageId: storageIds[i],
432
+ contentType: storageFiles[i].contentType,
433
+ deploymentId,
434
+ });
435
+ }
436
+ }
437
+ // Upload CDN files (still uses per-file calls since CDN has its own upload endpoint)
438
+ if (cdnFiles.length > 0 && cdnUploadBase) {
439
+ await forEachWithConcurrency(cdnFiles, concurrency, async (file) => {
440
+ const content = readFileSync(file.localPath);
441
+ const uploadResponse = await fetch(`${cdnUploadBase}/fs/upload`, {
442
+ method: "POST",
443
+ headers: { "Content-Type": file.contentType },
444
+ body: content,
445
+ });
446
+ if (!uploadResponse.ok) {
447
+ throw new Error(`CDN upload failed for ${file.path}: ${uploadResponse.status}`);
448
+ }
449
+ const { blobId } = (await uploadResponse.json());
450
+ if (typeof blobId !== "string") {
451
+ throw new Error("CDN upload returned no blobId for " + file.path);
452
+ }
453
+ uploaded.blobIds.push(blobId);
454
+ allAssets.push({
455
+ path: file.path,
456
+ blobId,
457
+ contentType: file.contentType,
458
+ deploymentId,
459
+ });
460
+ completed++;
461
+ console.log(` [${completed}/${total}] ${file.path} (cdn)`);
462
+ });
463
+ }
464
+ // Stage the manifest in portable command-line chunks. These rows are not
465
+ // served, so the previous deployment stays intact until publish succeeds.
466
+ const manifestChunks = chunkBySerializedArgument(allAssets, (assets) => ({
467
+ assets,
468
+ }));
469
+ for (let index = 0; index < manifestChunks.length; index++) {
470
+ console.log(` Staging manifest chunk ${index + 1}/${manifestChunks.length}...`);
471
+ await convexRunAsync(componentName, "lib:stageAssets", {
472
+ assets: manifestChunks[index],
473
+ });
474
+ }
475
+ // Publish the staged manifest, SPA setting, and old-asset GC in one
476
+ // mutation. The previous deployment stays intact if this call fails.
477
+ console.log(" Publishing deployment...");
478
+ publishAttempted = true;
479
+ const output = await convexRunAsync(componentName, "lib:publishDeployment", {
480
+ currentDeploymentId: deploymentId,
481
+ expectedAssetCount: allAssets.length,
482
+ spaFallback,
483
+ });
484
+ const result = JSON.parse(output);
485
+ if (typeof result.deleted !== "number" || typeof result.pendingBlobCleanup !== "number") {
486
+ throw new Error("Component returned an invalid publish result");
487
+ }
488
+ return {
489
+ deleted: result.deleted,
490
+ pendingBlobCleanup: result.pendingBlobCleanup,
491
+ };
492
+ }
493
+ catch (error) {
494
+ if (publishAttempted) {
495
+ try {
496
+ const output = await convexRunAsync(componentName, "lib:getCurrentDeployment");
497
+ const current = JSON.parse(output);
498
+ if (current?.currentDeploymentId === deploymentId) {
499
+ console.warn("The publish completed, but the CLI could not read a valid response. The new deployment is live and its files were kept.");
500
+ return {
501
+ deleted: 0,
502
+ pendingBlobCleanup: typeof current.pendingBlobCleanupCount === "number"
503
+ ? current.pendingBlobCleanupCount
504
+ : 0,
505
+ };
506
+ }
507
+ }
508
+ catch (reconcileError) {
509
+ console.warn("Warning: Could not determine whether the publish completed: " +
510
+ errorMessage(reconcileError));
511
+ }
512
+ }
513
+ await cleanUpFailedUpload(componentName, deploymentId, uploaded, cdnDeleteFunction, publishAttempted);
514
+ throw error;
515
+ }
516
+ }
517
+ function collectFiles(dir, baseDir) {
518
+ const files = [];
519
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
520
+ const fullPath = join(dir, entry.name);
521
+ if (entry.isDirectory()) {
522
+ files.push(...collectFiles(fullPath, baseDir));
523
+ }
524
+ else if (entry.isFile()) {
525
+ files.push({
526
+ path: "/" + relative(baseDir, fullPath).replace(/\\/g, "/"),
527
+ localPath: fullPath,
528
+ contentType: getMimeType(fullPath),
529
+ });
530
+ }
531
+ }
532
+ return files;
533
+ }
534
+ function estimateManifestBytes(files, deploymentId) {
535
+ // IDs returned by Convex storage and convex-fs are much shorter than this.
536
+ // The generous placeholder lets us reject oversized path metadata before
537
+ // uploading while the component still enforces the exact serialized size.
538
+ const locationPlaceholder = "x".repeat(128);
539
+ return Buffer.byteLength(JSON.stringify(files.map((file) => ({
540
+ path: file.path,
541
+ storageId: locationPlaceholder,
542
+ contentType: file.contentType,
543
+ deploymentId,
544
+ }))));
545
+ }
546
+ async function main() {
547
+ const args = parseUploadArgs(process.argv.slice(2));
548
+ if (args.help) {
549
+ showHelp();
550
+ process.exit(0);
551
+ }
552
+ // Set global prod flag
553
+ useProd = args.prod;
554
+ previewName = args.previewName;
555
+ // The component knows both its CONVEX_SITE_URL (where the app is served,
556
+ // including the mount prefix) and CONVEX_CLOUD_URL (what the frontend
557
+ // connects to). We fetch both in one call and trust neither hostname. This
558
+ // also resolves the instance name actually mounted (default or legacy).
559
+ const { urls: { siteUrl: componentSiteUrl, cloudUrl: convexUrl }, componentName: resolvedComponentName, } = await fetchUrls(args.component);
560
+ // Run build if requested
561
+ if (args.build) {
562
+ const basePath = new URL(componentSiteUrl).pathname || "/";
563
+ const envLabel = previewName === null ? (useProd ? "production" : "development") : `preview (${previewName})`;
564
+ console.log(`🔨 Building for ${envLabel}...`);
565
+ console.log(` Build command: ${args.buildCommand}`);
566
+ console.log(` VITE_CONVEX_URL=${convexUrl}`);
567
+ console.log(` CONVEX_HOSTING_BASE_PATH=${basePath}`);
568
+ console.log("");
569
+ const buildResult = spawnShell(args.buildCommand, {
570
+ ...process.env,
571
+ VITE_CONVEX_URL: convexUrl,
572
+ CONVEX_HOSTING_BASE_PATH: basePath,
573
+ });
574
+ if (buildResult !== 0) {
575
+ console.error("Build failed.");
576
+ process.exit(1);
577
+ }
578
+ console.log("");
579
+ }
580
+ const distDir = resolve(args.dist);
581
+ const componentName = resolvedComponentName;
582
+ const useCdn = args.cdn;
583
+ // Convex storage deployment
584
+ if (!existsSync(distDir)) {
585
+ console.error(`Error: dist directory not found: ${distDir}`);
586
+ console.error("Run your build command first (e.g., 'npm run build' or add --build flag)");
587
+ process.exit(1);
588
+ }
589
+ // /fs/upload lives at the deployment root, not under the component's
590
+ // mount prefix.
591
+ const cdnUploadBase = useCdn ? new URL(componentSiteUrl).origin : null;
592
+ const deploymentId = randomUUID();
593
+ const files = collectFiles(distDir, distDir);
594
+ if (files.length === 0) {
595
+ console.error(`Error: dist directory is empty: ${distDir}`);
596
+ console.error("Refusing to replace the live deployment with no files.");
597
+ process.exit(1);
598
+ }
599
+ if (!files.some((file) => file.path === "/index.html")) {
600
+ console.error(`Error: dist directory has no index.html: ${distDir}`);
601
+ console.error("Refusing to publish a static site without its entry point.");
602
+ process.exit(1);
603
+ }
604
+ if (files.length > MAX_ASSETS_PER_DEPLOYMENT) {
605
+ console.error(`Error: dist directory contains ${files.length} files; the supported maximum is ${MAX_ASSETS_PER_DEPLOYMENT}.`);
606
+ console.error("Reduce the number of emitted files so publication stays within Convex transaction limits.");
607
+ process.exit(1);
608
+ }
609
+ const estimatedManifestBytes = estimateManifestBytes(files, deploymentId);
610
+ if (estimatedManifestBytes > MAX_MANIFEST_SERIALIZED_BYTES) {
611
+ console.error(`Error: asset manifest metadata is approximately ${estimatedManifestBytes} bytes; the supported maximum is ${MAX_MANIFEST_SERIALIZED_BYTES}.`);
612
+ console.error("Shorten emitted asset paths or reduce the number of files so publication stays within Convex transaction byte limits.");
613
+ process.exit(1);
614
+ }
615
+ await cleanUpAbandonedUploads(componentName, args.cdnDeleteFunction);
616
+ const envLabel = previewName === null ? (useProd ? "production" : "development") : `preview (${previewName})`;
617
+ console.log(`🚀 Deploying to ${envLabel} environment`);
618
+ if (useCdn) {
619
+ console.log("☁️ CDN mode: non-HTML assets will be uploaded to convex-fs");
620
+ }
621
+ console.log("🔒 Using secure internal functions (requires Convex CLI auth)");
622
+ console.log(`Uploading ${files.length} files with deployment ID: ${deploymentId}`);
623
+ console.log(`Component: ${componentName}`);
624
+ console.log("");
625
+ const publishResult = await uploadWithConcurrency(files, componentName, deploymentId, useCdn, cdnUploadBase, args.concurrency, args.spaFallback, args.cdnDeleteFunction);
626
+ console.log("");
627
+ let deletedCount = publishResult.deleted;
628
+ try {
629
+ // Publish queues only IDs from the old live manifest. Deleting that queue
630
+ // cannot race with files from another uploader that are not staged yet.
631
+ deletedCount += await cleanUpPendingStorage(componentName);
632
+ }
633
+ catch (cleanupError) {
634
+ console.warn("Warning: The new deployment is live, but old component storage could not be fully removed: " +
635
+ errorMessage(cleanupError));
636
+ }
637
+ if (deletedCount > 0) {
638
+ console.log(`Cleaned up ${deletedCount} old storage file(s) from previous deployments`);
639
+ }
640
+ // Old CDN IDs are persisted by the component until the app-level delete
641
+ // function succeeds, including when the publish response was lost.
642
+ if (args.cdnDeleteFunction) {
643
+ try {
644
+ const cleaned = await cleanUpPendingCdnBlobs(componentName, args.cdnDeleteFunction);
645
+ if (cleaned > 0) {
646
+ console.log(`Cleaned up ${cleaned} old CDN blob(s) from previous deployments`);
647
+ }
648
+ }
649
+ catch (error) {
650
+ console.warn("Warning: Could not delete old CDN blobs via " +
651
+ args.cdnDeleteFunction +
652
+ ": " +
653
+ errorMessage(error));
654
+ }
655
+ }
656
+ else if (publishResult.pendingBlobCleanup > 0) {
657
+ console.log(`${publishResult.pendingBlobCleanup} CDN blob(s) are pending cleanup. Pass --cdn-delete-function to delete them.`);
658
+ }
659
+ console.log("");
660
+ console.log("✨ Upload complete!");
661
+ console.log("");
662
+ console.log(`Your app is now available at: ${componentSiteUrl}`);
663
+ }
664
+ main().catch((error) => {
665
+ console.error("Upload failed:", error);
666
+ process.exit(1);
667
+ });
668
+ //# sourceMappingURL=upload.js.map