@convex-dev/static-hosting 0.1.2-beta.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 (98) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +333 -0
  3. package/dist/cli/deploy.d.ts +16 -0
  4. package/dist/cli/deploy.d.ts.map +1 -0
  5. package/dist/cli/deploy.js +324 -0
  6. package/dist/cli/deploy.js.map +1 -0
  7. package/dist/cli/index.d.ts +15 -0
  8. package/dist/cli/index.d.ts.map +1 -0
  9. package/dist/cli/index.js +95 -0
  10. package/dist/cli/index.js.map +1 -0
  11. package/dist/cli/init.d.ts +9 -0
  12. package/dist/cli/init.d.ts.map +1 -0
  13. package/dist/cli/init.js +181 -0
  14. package/dist/cli/init.js.map +1 -0
  15. package/dist/cli/next-build.d.ts +24 -0
  16. package/dist/cli/next-build.d.ts.map +1 -0
  17. package/dist/cli/next-build.js +569 -0
  18. package/dist/cli/next-build.js.map +1 -0
  19. package/dist/cli/setup.d.ts +9 -0
  20. package/dist/cli/setup.d.ts.map +1 -0
  21. package/dist/cli/setup.js +157 -0
  22. package/dist/cli/setup.js.map +1 -0
  23. package/dist/cli/upload.d.ts +15 -0
  24. package/dist/cli/upload.d.ts.map +1 -0
  25. package/dist/cli/upload.js +436 -0
  26. package/dist/cli/upload.js.map +1 -0
  27. package/dist/client/_generated/_ignore.d.ts +1 -0
  28. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  29. package/dist/client/_generated/_ignore.js +3 -0
  30. package/dist/client/_generated/_ignore.js.map +1 -0
  31. package/dist/client/index.d.ts +142 -0
  32. package/dist/client/index.d.ts.map +1 -0
  33. package/dist/client/index.js +475 -0
  34. package/dist/client/index.js.map +1 -0
  35. package/dist/client/next.d.ts +38 -0
  36. package/dist/client/next.d.ts.map +1 -0
  37. package/dist/client/next.js +175 -0
  38. package/dist/client/next.js.map +1 -0
  39. package/dist/client/nextAdapter.d.ts +4 -0
  40. package/dist/client/nextAdapter.d.ts.map +1 -0
  41. package/dist/client/nextAdapter.js +9 -0
  42. package/dist/client/nextAdapter.js.map +1 -0
  43. package/dist/component/_generated/api.d.ts +34 -0
  44. package/dist/component/_generated/api.d.ts.map +1 -0
  45. package/dist/component/_generated/api.js +31 -0
  46. package/dist/component/_generated/api.js.map +1 -0
  47. package/dist/component/_generated/component.d.ts +73 -0
  48. package/dist/component/_generated/component.d.ts.map +1 -0
  49. package/dist/component/_generated/component.js +11 -0
  50. package/dist/component/_generated/component.js.map +1 -0
  51. package/dist/component/_generated/dataModel.d.ts +46 -0
  52. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  53. package/dist/component/_generated/dataModel.js +11 -0
  54. package/dist/component/_generated/dataModel.js.map +1 -0
  55. package/dist/component/_generated/server.d.ts +121 -0
  56. package/dist/component/_generated/server.d.ts.map +1 -0
  57. package/dist/component/_generated/server.js +78 -0
  58. package/dist/component/_generated/server.js.map +1 -0
  59. package/dist/component/convex.config.d.ts +3 -0
  60. package/dist/component/convex.config.d.ts.map +1 -0
  61. package/dist/component/convex.config.js +3 -0
  62. package/dist/component/convex.config.js.map +1 -0
  63. package/dist/component/lib.d.ts +88 -0
  64. package/dist/component/lib.d.ts.map +1 -0
  65. package/dist/component/lib.js +210 -0
  66. package/dist/component/lib.js.map +1 -0
  67. package/dist/component/schema.d.ts +27 -0
  68. package/dist/component/schema.d.ts.map +1 -0
  69. package/dist/component/schema.js +20 -0
  70. package/dist/component/schema.js.map +1 -0
  71. package/dist/react/index.d.ts +80 -0
  72. package/dist/react/index.d.ts.map +1 -0
  73. package/dist/react/index.js +138 -0
  74. package/dist/react/index.js.map +1 -0
  75. package/package.json +120 -0
  76. package/src/cli/deploy.ts +375 -0
  77. package/src/cli/index.ts +104 -0
  78. package/src/cli/init.ts +181 -0
  79. package/src/cli/next-build.ts +707 -0
  80. package/src/cli/setup.ts +190 -0
  81. package/src/cli/upload.ts +521 -0
  82. package/src/client/_generated/_ignore.ts +1 -0
  83. package/src/client/index.test.ts +67 -0
  84. package/src/client/index.ts +553 -0
  85. package/src/client/next.ts +223 -0
  86. package/src/client/nextAdapter.ts +17 -0
  87. package/src/client/setup.test.ts +26 -0
  88. package/src/component/_generated/api.ts +50 -0
  89. package/src/component/_generated/component.ts +104 -0
  90. package/src/component/_generated/dataModel.ts +60 -0
  91. package/src/component/_generated/server.ts +161 -0
  92. package/src/component/convex.config.ts +3 -0
  93. package/src/component/lib.test.ts +110 -0
  94. package/src/component/lib.ts +228 -0
  95. package/src/component/schema.ts +21 -0
  96. package/src/component/setup.test.ts +11 -0
  97. package/src/react/index.tsx +184 -0
  98. package/src/test.ts +18 -0
@@ -0,0 +1,67 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { exposeUploadApi, getMimeType } from "./index.js";
3
+ import { anyApi, type ApiFromModules } from "convex/server";
4
+ import { components, initConvexTest } from "./setup.test.js";
5
+
6
+ export const { generateUploadUrl, recordAsset, gcOldAssets, listAssets } =
7
+ exposeUploadApi(components.staticHosting);
8
+
9
+ const testApi = (
10
+ anyApi as unknown as ApiFromModules<{
11
+ "index.test": {
12
+ generateUploadUrl: typeof generateUploadUrl;
13
+ recordAsset: typeof recordAsset;
14
+ gcOldAssets: typeof gcOldAssets;
15
+ listAssets: typeof listAssets;
16
+ };
17
+ }>
18
+ )["index.test"];
19
+
20
+ describe("client tests", () => {
21
+ test("should expose upload API functions", async () => {
22
+ const t = initConvexTest();
23
+
24
+ // Test generateUploadUrl
25
+ const uploadUrl = await t.mutation(testApi.generateUploadUrl, {});
26
+ expect(uploadUrl).toBeDefined();
27
+ expect(typeof uploadUrl).toBe("string");
28
+ });
29
+
30
+ test("should list empty assets initially", async () => {
31
+ const t = initConvexTest();
32
+
33
+ const assets = await t.query(testApi.listAssets, {});
34
+ expect(assets).toHaveLength(0);
35
+ });
36
+
37
+ test("gc should return 0 with no assets", async () => {
38
+ const t = initConvexTest();
39
+
40
+ const result = await t.mutation(testApi.gcOldAssets, {
41
+ currentDeploymentId: "test-deployment",
42
+ });
43
+ expect(result.deleted).toBe(0);
44
+ expect(result.blobIds).toHaveLength(0);
45
+ });
46
+ });
47
+
48
+ describe("getMimeType", () => {
49
+ test("returns correct MIME types for common extensions", () => {
50
+ expect(getMimeType("/index.html")).toBe("text/html; charset=utf-8");
51
+ expect(getMimeType("/assets/main.js")).toBe(
52
+ "application/javascript; charset=utf-8",
53
+ );
54
+ expect(getMimeType("/styles/app.css")).toBe("text/css; charset=utf-8");
55
+ expect(getMimeType("/data.json")).toBe("application/json; charset=utf-8");
56
+ expect(getMimeType("/image.png")).toBe("image/png");
57
+ expect(getMimeType("/photo.jpg")).toBe("image/jpeg");
58
+ expect(getMimeType("/icon.svg")).toBe("image/svg+xml");
59
+ expect(getMimeType("/favicon.ico")).toBe("image/x-icon");
60
+ expect(getMimeType("/font.woff2")).toBe("font/woff2");
61
+ });
62
+
63
+ test("returns octet-stream for unknown extensions", () => {
64
+ expect(getMimeType("/file.xyz")).toBe("application/octet-stream");
65
+ expect(getMimeType("/unknown")).toBe("application/octet-stream");
66
+ });
67
+ });
@@ -0,0 +1,553 @@
1
+ import {
2
+ httpActionGeneric,
3
+ internalMutationGeneric,
4
+ internalQueryGeneric,
5
+ queryGeneric,
6
+ } from "convex/server";
7
+ import type { HttpRouter } from "convex/server";
8
+ import { v } from "convex/values";
9
+ import type { ComponentApi } from "../component/_generated/component.js";
10
+
11
+ // MIME type mapping for common file types
12
+ const MIME_TYPES: Record<string, string> = {
13
+ ".html": "text/html; charset=utf-8",
14
+ ".js": "application/javascript; charset=utf-8",
15
+ ".mjs": "application/javascript; charset=utf-8",
16
+ ".css": "text/css; charset=utf-8",
17
+ ".json": "application/json; charset=utf-8",
18
+ ".png": "image/png",
19
+ ".jpg": "image/jpeg",
20
+ ".jpeg": "image/jpeg",
21
+ ".gif": "image/gif",
22
+ ".svg": "image/svg+xml",
23
+ ".ico": "image/x-icon",
24
+ ".webp": "image/webp",
25
+ ".woff": "font/woff",
26
+ ".woff2": "font/woff2",
27
+ ".ttf": "font/ttf",
28
+ ".txt": "text/plain; charset=utf-8",
29
+ ".map": "application/json",
30
+ ".webmanifest": "application/manifest+json",
31
+ ".xml": "application/xml",
32
+ };
33
+
34
+ /**
35
+ * Generate HTML page shown when no assets have been deployed yet.
36
+ */
37
+ function getSetupHtml(): string {
38
+ return `<!DOCTYPE html>
39
+ <html lang="en">
40
+ <head>
41
+ <meta charset="UTF-8">
42
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
43
+ <title>Convex Static Hosting</title>
44
+ <style>
45
+ * { box-sizing: border-box; }
46
+ body {
47
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
48
+ max-width: 640px;
49
+ margin: 0 auto;
50
+ padding: 40px 20px;
51
+ background: #fafafa;
52
+ color: #333;
53
+ line-height: 1.6;
54
+ }
55
+ h1 { color: #111; margin-bottom: 8px; }
56
+ .subtitle { color: #666; margin-bottom: 32px; }
57
+ code {
58
+ background: #e8e8e8;
59
+ padding: 2px 6px;
60
+ border-radius: 4px;
61
+ font-size: 14px;
62
+ }
63
+ pre {
64
+ background: #1a1a1a;
65
+ color: #f0f0f0;
66
+ padding: 16px;
67
+ border-radius: 8px;
68
+ overflow-x: auto;
69
+ font-size: 14px;
70
+ }
71
+ .step { margin-bottom: 24px; }
72
+ .step-num {
73
+ display: inline-block;
74
+ background: #333;
75
+ color: white;
76
+ width: 24px;
77
+ height: 24px;
78
+ border-radius: 50%;
79
+ text-align: center;
80
+ font-size: 14px;
81
+ line-height: 24px;
82
+ margin-right: 8px;
83
+ }
84
+ a { color: #0070f3; }
85
+ </style>
86
+ </head>
87
+ <body>
88
+ <h1>Almost there!</h1>
89
+ <p class="subtitle">Your Convex backend is running, but no static files have been deployed yet.</p>
90
+
91
+ <div class="step">
92
+ <span class="step-num">1</span>
93
+ <strong>Build your frontend</strong>
94
+ <pre>npm run build</pre>
95
+ </div>
96
+
97
+ <div class="step">
98
+ <span class="step-num">2</span>
99
+ <strong>Deploy your static files</strong>
100
+ <pre>npx @convex-dev/static-hosting deploy</pre>
101
+ </div>
102
+
103
+ <p>Or deploy everything in one command:</p>
104
+ <pre>npm run deploy</pre>
105
+
106
+ <p style="margin-top: 32px; color: #666; font-size: 14px;">
107
+ Learn more at <a href="https://github.com/get-convex/static-hosting">github.com/get-convex/static-hosting</a>
108
+ </p>
109
+ </body>
110
+ </html>`;
111
+ }
112
+
113
+ /**
114
+ * Get MIME type for a file path based on its extension.
115
+ */
116
+ export function getMimeType(path: string): string {
117
+ const ext = path.substring(path.lastIndexOf(".")).toLowerCase();
118
+ return MIME_TYPES[ext] || "application/octet-stream";
119
+ }
120
+
121
+ /**
122
+ * Check if a path has a file extension.
123
+ */
124
+ function hasFileExtension(path: string): boolean {
125
+ const lastSegment = path.split("/").pop() || "";
126
+ return lastSegment.includes(".") && !lastSegment.startsWith(".");
127
+ }
128
+
129
+ /**
130
+ * Check if asset is a hashed asset (for cache control).
131
+ * Vite produces: index-lj_vq_aF.js, style-B71cUw87.css
132
+ */
133
+ function isHashedAsset(path: string): boolean {
134
+ return /[-.][\dA-Za-z_]{6,12}\.[a-z]+$/.test(path);
135
+ }
136
+
137
+ /**
138
+ * Register HTTP routes for serving static files.
139
+ * This creates a catch-all route that serves files from Convex storage
140
+ * with SPA fallback support.
141
+ *
142
+ * @param http - The HTTP router to register routes on
143
+ * @param component - The component API reference
144
+ * @param options - Configuration options
145
+ * @param options.pathPrefix - URL prefix for static files (default: "/")
146
+ * @param options.spaFallback - Enable SPA fallback to index.html (default: true)
147
+ *
148
+ * @example
149
+ * ```typescript
150
+ * // In your convex/http.ts
151
+ * import { httpRouter } from "convex/server";
152
+ * import { registerStaticRoutes } from "@convex-dev/static-hosting";
153
+ * import { components } from "./_generated/api";
154
+ *
155
+ * const http = httpRouter();
156
+ *
157
+ * // Serve static files at root
158
+ * registerStaticRoutes(http, components.staticHosting);
159
+ *
160
+ * // Or serve at a specific path prefix
161
+ * registerStaticRoutes(http, components.staticHosting, {
162
+ * pathPrefix: "/app",
163
+ * });
164
+ *
165
+ * export default http;
166
+ * ```
167
+ */
168
+ /**
169
+ * Check if a content type is HTML.
170
+ */
171
+ function isHtmlContentType(contentType: string): boolean {
172
+ return contentType.startsWith("text/html");
173
+ }
174
+
175
+ export function registerStaticRoutes(
176
+ http: HttpRouter,
177
+ component: ComponentApi,
178
+ {
179
+ pathPrefix = "/",
180
+ spaFallback = true,
181
+ cdnBaseUrl,
182
+ }: {
183
+ pathPrefix?: string;
184
+ spaFallback?: boolean;
185
+ /** Base URL for CDN blob redirects (e.g., `(req) => \`${new URL(req.url).origin}/fs/blobs\``).
186
+ * When set, assets with a blobId (non-HTML) will return a 302 redirect to `{cdnBaseUrl}/{blobId}`. */
187
+ cdnBaseUrl?: string | ((request: Request) => string);
188
+ } = {},
189
+ ) {
190
+ // Normalize pathPrefix - ensure it starts with / and doesn't end with /
191
+ const normalizedPrefix =
192
+ pathPrefix === "/" ? "" : pathPrefix.replace(/\/$/, "");
193
+
194
+ const serveStaticFile = httpActionGeneric(async (ctx, request) => {
195
+ const url = new URL(request.url);
196
+ let path = url.pathname;
197
+
198
+ // Remove prefix if present
199
+ if (normalizedPrefix && path.startsWith(normalizedPrefix)) {
200
+ path = path.slice(normalizedPrefix.length) || "/";
201
+ }
202
+
203
+ // Normalize: serve index.html for root
204
+ if (path === "" || path === "/") {
205
+ path = "/index.html";
206
+ }
207
+
208
+ // Look up the asset
209
+ type AssetDoc = {
210
+ _id: string;
211
+ _creationTime: number;
212
+ path: string;
213
+ storageId?: string;
214
+ blobId?: string;
215
+ contentType: string;
216
+ deploymentId: string;
217
+ } | null;
218
+
219
+ let asset: AssetDoc = await ctx.runQuery(component.lib.getByPath, { path });
220
+
221
+ // SPA fallback: if not found and no file extension, serve index.html
222
+ if (!asset && spaFallback && !hasFileExtension(path)) {
223
+ asset = await ctx.runQuery(component.lib.getByPath, {
224
+ path: "/index.html",
225
+ });
226
+ }
227
+
228
+ // 404 if still not found
229
+ if (!asset) {
230
+ // If looking for index.html and it's not there, show setup instructions
231
+ if (path === "/index.html") {
232
+ return new Response(getSetupHtml(), {
233
+ status: 200,
234
+ headers: { "Content-Type": "text/html; charset=utf-8" },
235
+ });
236
+ }
237
+ return new Response("Not Found", {
238
+ status: 404,
239
+ headers: { "Content-Type": "text/plain" },
240
+ });
241
+ }
242
+
243
+ // CDN redirect: if asset has blobId, is not HTML, and cdnBaseUrl is configured
244
+ if (asset.blobId && cdnBaseUrl && !isHtmlContentType(asset.contentType)) {
245
+ const baseUrl =
246
+ typeof cdnBaseUrl === "function" ? cdnBaseUrl(request) : cdnBaseUrl;
247
+ const redirectUrl = `${baseUrl.replace(/\/$/, "")}/${asset.blobId}`;
248
+
249
+ // Cache control for redirect: hashed assets can cache the redirect itself
250
+ const cacheControl = isHashedAsset(path)
251
+ ? "public, max-age=31536000, immutable"
252
+ : "public, max-age=0, must-revalidate";
253
+
254
+ return new Response(null, {
255
+ status: 302,
256
+ headers: {
257
+ Location: redirectUrl,
258
+ "Cache-Control": cacheControl,
259
+ },
260
+ });
261
+ }
262
+
263
+ // Serve from Convex storage
264
+ if (!asset.storageId) {
265
+ return new Response("Asset not available", {
266
+ status: 500,
267
+ headers: { "Content-Type": "text/plain" },
268
+ });
269
+ }
270
+
271
+ // Use storageId as ETag (unique per file content)
272
+ const etag = `"${asset.storageId}"`;
273
+
274
+ // Check for conditional request (If-None-Match)
275
+ const ifNoneMatch = request.headers.get("If-None-Match");
276
+ if (ifNoneMatch === etag) {
277
+ // Client has current version - return 304 Not Modified
278
+ return new Response(null, {
279
+ status: 304,
280
+ headers: {
281
+ ETag: etag,
282
+ "Cache-Control": isHashedAsset(path)
283
+ ? "public, max-age=31536000, immutable"
284
+ : "public, max-age=0, must-revalidate",
285
+ },
286
+ });
287
+ }
288
+
289
+ // Get file from storage
290
+ const blob = await ctx.storage.get(asset.storageId);
291
+ if (!blob) {
292
+ return new Response("Storage error", {
293
+ status: 500,
294
+ headers: { "Content-Type": "text/plain" },
295
+ });
296
+ }
297
+
298
+ // Cache control: hashed assets can be cached forever
299
+ const cacheControl = isHashedAsset(path)
300
+ ? "public, max-age=31536000, immutable"
301
+ : "public, max-age=0, must-revalidate";
302
+
303
+ return new Response(blob, {
304
+ status: 200,
305
+ headers: {
306
+ "Content-Type": asset.contentType,
307
+ "Cache-Control": cacheControl,
308
+ ETag: etag,
309
+ "X-Content-Type-Options": "nosniff",
310
+ },
311
+ });
312
+ });
313
+
314
+ // Use pathPrefix routing
315
+ http.route({
316
+ pathPrefix: pathPrefix === "/" ? "/" : `${normalizedPrefix}/`,
317
+ method: "GET",
318
+ handler: serveStaticFile,
319
+ });
320
+
321
+ // Also handle exact prefix without trailing slash
322
+ if (normalizedPrefix) {
323
+ http.route({
324
+ path: normalizedPrefix,
325
+ method: "GET",
326
+ handler: serveStaticFile,
327
+ });
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Expose the upload API as INTERNAL functions for secure deployments.
333
+ * These functions can only be called via `npx convex run` or from other Convex functions.
334
+ *
335
+ * @param component - The component API reference
336
+ *
337
+ * @example
338
+ * ```typescript
339
+ * // In your convex/staticHosting.ts
340
+ * import { exposeUploadApi } from "@convex-dev/static-hosting";
341
+ * import { components } from "./_generated/api";
342
+ *
343
+ * export const { generateUploadUrl, recordAsset, gcOldAssets, listAssets } =
344
+ * exposeUploadApi(components.staticHosting);
345
+ * ```
346
+ *
347
+ * Then deploy using:
348
+ * ```bash
349
+ * npm run deploy:static
350
+ * ```
351
+ */
352
+ export function exposeUploadApi(component: ComponentApi) {
353
+ return {
354
+ /**
355
+ * Generate a signed URL for uploading a file.
356
+ * Files are stored in the app's storage (not the component's).
357
+ */
358
+ generateUploadUrl: internalMutationGeneric({
359
+ args: {},
360
+ handler: async (ctx) => {
361
+ return await ctx.storage.generateUploadUrl();
362
+ },
363
+ }),
364
+
365
+ /**
366
+ * Record an uploaded asset in the database.
367
+ * Automatically cleans up old storage files when replacing.
368
+ * Pass storageId for Convex storage assets, or blobId for CDN assets.
369
+ */
370
+ recordAsset: internalMutationGeneric({
371
+ args: {
372
+ path: v.string(),
373
+ storageId: v.optional(v.string()),
374
+ blobId: v.optional(v.string()),
375
+ contentType: v.string(),
376
+ deploymentId: v.string(),
377
+ },
378
+ handler: async (ctx, args) => {
379
+ const { oldStorageId, oldBlobId } = await ctx.runMutation(
380
+ component.lib.recordAsset,
381
+ {
382
+ path: args.path,
383
+ ...(args.storageId ? { storageId: args.storageId } : {}),
384
+ ...(args.blobId ? { blobId: args.blobId } : {}),
385
+ contentType: args.contentType,
386
+ deploymentId: args.deploymentId,
387
+ },
388
+ );
389
+ if (oldStorageId) {
390
+ try {
391
+ await ctx.storage.delete(oldStorageId);
392
+ } catch {
393
+ // Ignore - old file may have been in different storage
394
+ }
395
+ }
396
+ // Return oldBlobId so caller can clean up CDN blobs if needed
397
+ return oldBlobId ?? null;
398
+ },
399
+ }),
400
+
401
+ /**
402
+ * Garbage collect old assets and notify clients of the new deployment.
403
+ * Returns the count of deleted assets.
404
+ * Also triggers connected clients to reload via the deployment subscription.
405
+ */
406
+ gcOldAssets: internalMutationGeneric({
407
+ args: {
408
+ currentDeploymentId: v.string(),
409
+ },
410
+ handler: async (ctx, args) => {
411
+ const { storageIds, blobIds } = await ctx.runMutation(
412
+ component.lib.gcOldAssets,
413
+ {
414
+ currentDeploymentId: args.currentDeploymentId,
415
+ },
416
+ );
417
+ for (const storageId of storageIds) {
418
+ try {
419
+ await ctx.storage.delete(storageId);
420
+ } catch {
421
+ // Ignore - old file may have been in different storage
422
+ }
423
+ }
424
+
425
+ // Update deployment info to trigger client reloads
426
+ await ctx.runMutation(component.lib.setCurrentDeployment, {
427
+ deploymentId: args.currentDeploymentId,
428
+ });
429
+
430
+ // Return both counts and blobIds for CDN cleanup
431
+ return { deleted: storageIds.length, blobIds };
432
+ },
433
+ }),
434
+
435
+ /**
436
+ * Generate multiple signed upload URLs in one call.
437
+ * Much faster than calling generateUploadUrl N times.
438
+ */
439
+ generateUploadUrls: internalMutationGeneric({
440
+ args: { count: v.number() },
441
+ handler: async (ctx, { count }) => {
442
+ const urls: string[] = [];
443
+ for (let i = 0; i < count; i++) {
444
+ urls.push(await ctx.storage.generateUploadUrl());
445
+ }
446
+ return urls;
447
+ },
448
+ }),
449
+
450
+ /**
451
+ * Record multiple uploaded assets in one call.
452
+ */
453
+ recordAssets: internalMutationGeneric({
454
+ args: {
455
+ assets: v.array(
456
+ v.object({
457
+ path: v.string(),
458
+ storageId: v.string(),
459
+ contentType: v.string(),
460
+ deploymentId: v.string(),
461
+ }),
462
+ ),
463
+ },
464
+ handler: async (ctx, { assets }) => {
465
+ for (const asset of assets) {
466
+ await ctx.runMutation(component.lib.recordAsset, {
467
+ path: asset.path,
468
+ storageId: asset.storageId,
469
+ contentType: asset.contentType,
470
+ deploymentId: asset.deploymentId,
471
+ });
472
+ }
473
+ },
474
+ }),
475
+
476
+ /**
477
+ * List all static assets (for debugging).
478
+ */
479
+ listAssets: internalQueryGeneric({
480
+ args: {
481
+ limit: v.optional(v.number()),
482
+ },
483
+ handler: async (ctx, args) => {
484
+ return await ctx.runQuery(component.lib.listAssets, {
485
+ limit: args.limit,
486
+ });
487
+ },
488
+ }),
489
+ };
490
+ }
491
+
492
+ /**
493
+ * Expose a query that clients can subscribe to for live reload on deploy.
494
+ * When a new deployment happens, subscribed clients will be notified.
495
+ *
496
+ * @param component - The component API reference
497
+ *
498
+ * @example
499
+ * ```typescript
500
+ * // In your convex/staticHosting.ts
501
+ * import { exposeUploadApi, exposeDeploymentQuery } from "@convex-dev/static-hosting";
502
+ * import { components } from "./_generated/api";
503
+ *
504
+ * export const { generateUploadUrl, recordAsset, gcOldAssets, listAssets } =
505
+ * exposeUploadApi(components.staticHosting);
506
+ *
507
+ * export const { getCurrentDeployment } = exposeDeploymentQuery(components.staticHosting);
508
+ * ```
509
+ */
510
+ export function exposeDeploymentQuery(component: ComponentApi) {
511
+ return {
512
+ /**
513
+ * Get the current deployment info.
514
+ * Subscribe to this query to detect when a new deployment happens.
515
+ */
516
+ getCurrentDeployment: queryGeneric({
517
+ args: {},
518
+ handler: async (ctx) => {
519
+ return await ctx.runQuery(component.lib.getCurrentDeployment, {});
520
+ },
521
+ }),
522
+ };
523
+ }
524
+
525
+ /**
526
+ * Derive the Convex cloud URL from a .convex.site hostname.
527
+ * Useful for client-side code that needs to connect to the Convex backend
528
+ * when hosted on Convex static hosting.
529
+ *
530
+ * @example
531
+ * ```typescript
532
+ * // In your React app's main.tsx
533
+ * import { getConvexUrl } from "@convex-dev/static-hosting";
534
+ *
535
+ * const convexUrl = import.meta.env.VITE_CONVEX_URL ?? getConvexUrl();
536
+ * const convex = new ConvexReactClient(convexUrl);
537
+ * ```
538
+ */
539
+ export function getConvexUrl(): string {
540
+ if (typeof window === "undefined") {
541
+ throw new Error("getConvexUrl() can only be called in a browser context");
542
+ }
543
+
544
+ // If hosted on Convex (.convex.site), derive API URL (.convex.cloud)
545
+ if (window.location.hostname.endsWith(".convex.site")) {
546
+ return `https://${window.location.hostname.replace(".convex.site", ".convex.cloud")}`;
547
+ }
548
+
549
+ throw new Error(
550
+ "Unable to derive Convex URL. Please set VITE_CONVEX_URL environment variable.",
551
+ );
552
+ }
553
+