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