@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,707 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI tool to build and prepare a Next.js app for Convex deployment.
4
+ *
5
+ * This tool:
6
+ * 1. Runs `next build` (output: standalone)
7
+ * 2. Collects server-side files from the standalone build
8
+ * 3. Generates `convex/_generatedNextServer.ts` with embedded file contents
9
+ * 4. Uploads static assets (.next/static/) to Convex storage
10
+ * 5. Ensures convex.json has node.externalPackages: ["next"]
11
+ *
12
+ * Usage:
13
+ * npx @convex-dev/static-hosting next-build [options]
14
+ *
15
+ * Options:
16
+ * --skip-build Skip running `next build`
17
+ * --component <name> Convex component name (default: staticHosting)
18
+ * --convex-dir <path> Path to convex/ directory (default: ./convex)
19
+ * --prod Upload statics to production deployment
20
+ * --skip-upload Skip uploading static files
21
+ * --help Show help
22
+ */
23
+
24
+ import {
25
+ readFileSync,
26
+ readdirSync,
27
+ writeFileSync,
28
+ existsSync,
29
+ statSync,
30
+ mkdirSync,
31
+ } from "node:fs";
32
+ import { join, relative, extname, resolve, dirname } from "node:path";
33
+ import { randomUUID } from "node:crypto";
34
+ import { execFile, spawnSync } from "node:child_process";
35
+
36
+ // MIME type mapping
37
+ const MIME_TYPES: Record<string, string> = {
38
+ ".html": "text/html; charset=utf-8",
39
+ ".js": "application/javascript; charset=utf-8",
40
+ ".mjs": "application/javascript; charset=utf-8",
41
+ ".css": "text/css; charset=utf-8",
42
+ ".json": "application/json; charset=utf-8",
43
+ ".png": "image/png",
44
+ ".jpg": "image/jpeg",
45
+ ".jpeg": "image/jpeg",
46
+ ".gif": "image/gif",
47
+ ".svg": "image/svg+xml",
48
+ ".ico": "image/x-icon",
49
+ ".webp": "image/webp",
50
+ ".woff": "font/woff",
51
+ ".woff2": "font/woff2",
52
+ ".ttf": "font/ttf",
53
+ ".txt": "text/plain; charset=utf-8",
54
+ ".map": "application/json",
55
+ ".webmanifest": "application/manifest+json",
56
+ ".xml": "application/xml",
57
+ };
58
+
59
+ function getMimeType(filePath: string): string {
60
+ return MIME_TYPES[extname(filePath).toLowerCase()] || "application/octet-stream";
61
+ }
62
+
63
+ // File extensions considered text (embedded as UTF-8 strings)
64
+ const TEXT_EXTENSIONS = new Set([
65
+ ".js",
66
+ ".cjs",
67
+ ".mjs",
68
+ ".json",
69
+ ".html",
70
+ ".css",
71
+ ".txt",
72
+ ".xml",
73
+ ".rsc",
74
+ ".meta",
75
+ ".map",
76
+ ]);
77
+
78
+ // Top-level packages to exclude from embedded node_modules
79
+ // (not needed for SSR, saves significant bundle space)
80
+ const EXCLUDED_MODULES = new Set([
81
+ "@img", // Sharp native image binaries (~16MB)
82
+ "sharp", // Image optimization (~244KB but needs @img)
83
+ "typescript", // TypeScript compiler (~9MB) — not needed at runtime
84
+ "caniuse-lite", // Browser compat data (~2.4MB) — not needed for SSR
85
+ ]);
86
+
87
+ // Directories to exclude from server file collection
88
+ const EXCLUDED_DIRS = new Set(["static", "cache", "diagnostics", "trace"]);
89
+
90
+ interface ParsedArgs {
91
+ skipBuild: boolean;
92
+ component: string; // File name for convex run (e.g. "staticHosting")
93
+ componentApi: string; // Component API name from components.xxx (e.g. "staticHosting")
94
+ convexDir: string;
95
+ prod: boolean;
96
+ skipUpload: boolean;
97
+ forceColdStart: boolean;
98
+ help: boolean;
99
+ }
100
+
101
+ function parseArgs(argv: string[]): ParsedArgs {
102
+ const result: ParsedArgs = {
103
+ skipBuild: false,
104
+ component: "staticHosting",
105
+ componentApi: "staticHosting",
106
+ convexDir: "./convex",
107
+ prod: false,
108
+ skipUpload: false,
109
+ forceColdStart: false,
110
+ help: false,
111
+ };
112
+
113
+ for (let i = 0; i < argv.length; i++) {
114
+ const arg = argv[i];
115
+ if (arg === "--help" || arg === "-h") {
116
+ result.help = true;
117
+ } else if (arg === "--skip-build") {
118
+ result.skipBuild = true;
119
+ } else if (arg === "--force-cold-start") {
120
+ result.forceColdStart = true;
121
+ } else if (arg === "--component" || arg === "-c") {
122
+ result.component = argv[++i] || result.component;
123
+ } else if (arg === "--component-api") {
124
+ result.componentApi = argv[++i] || result.componentApi;
125
+ } else if (arg === "--convex-dir") {
126
+ result.convexDir = argv[++i] || result.convexDir;
127
+ } else if (arg === "--prod") {
128
+ result.prod = true;
129
+ } else if (arg === "--skip-upload") {
130
+ result.skipUpload = true;
131
+ }
132
+ }
133
+
134
+ return result;
135
+ }
136
+
137
+ function showHelp(): void {
138
+ console.log(`
139
+ Usage: npx @convex-dev/static-hosting next-build [options]
140
+
141
+ Build a Next.js app and prepare it for Convex deployment.
142
+
143
+ This command:
144
+ 1. Runs \`next build\` (output: standalone)
145
+ 2. Embeds server files into a generated Convex action
146
+ 3. Uploads static assets to Convex storage
147
+ 4. Configures convex.json for Next.js
148
+
149
+ Options:
150
+ --skip-build Skip running \`next build\` (use existing .next/)
151
+ -c, --component <name> Convex component name (default: staticHosting)
152
+ --convex-dir <path> Path to convex/ directory (default: ./convex)
153
+ --prod Upload statics to production deployment
154
+ --skip-upload Skip uploading static files (upload later)
155
+ -h, --help Show this help message
156
+
157
+ Examples:
158
+ # Full build and upload
159
+ npx @convex-dev/static-hosting next-build --prod
160
+
161
+ # Skip build, just regenerate and upload
162
+ npx @convex-dev/static-hosting next-build --skip-build --prod
163
+
164
+ # Generate only, upload later
165
+ npx @convex-dev/static-hosting next-build --skip-upload
166
+
167
+ After running, deploy your Convex backend:
168
+ npx convex deploy
169
+ `);
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // File collection
174
+ // ---------------------------------------------------------------------------
175
+
176
+ interface CollectedFile {
177
+ relativePath: string; // e.g. ".next/BUILD_ID" or "public/favicon.ico"
178
+ content: Buffer;
179
+ isText: boolean;
180
+ }
181
+
182
+ function isTextFile(filePath: string): boolean {
183
+ return TEXT_EXTENSIONS.has(extname(filePath).toLowerCase());
184
+ }
185
+
186
+ function collectFilesRecursive(
187
+ dir: string,
188
+ baseDir: string,
189
+ prefix: string,
190
+ excludeDirs: Set<string>,
191
+ files: CollectedFile[],
192
+ ): void {
193
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
194
+ const fullPath = join(dir, entry.name);
195
+
196
+ if (entry.isDirectory()) {
197
+ // Skip excluded directories (only at top level under .next/)
198
+ const relFromBase = relative(baseDir, fullPath);
199
+ const topDir = relFromBase.split("/")[0];
200
+ if (excludeDirs.has(topDir)) continue;
201
+
202
+ // Skip .nft.json trace directories
203
+ if (entry.name.endsWith(".nft.json")) continue;
204
+
205
+ collectFilesRecursive(fullPath, baseDir, prefix, new Set(), files);
206
+ } else if (entry.isFile()) {
207
+ // Skip .nft.json trace files
208
+ if (entry.name.endsWith(".nft.json")) continue;
209
+
210
+ const relPath = prefix + "/" + relative(baseDir, fullPath);
211
+ files.push({
212
+ relativePath: relPath,
213
+ content: readFileSync(fullPath),
214
+ isText: isTextFile(fullPath),
215
+ });
216
+ }
217
+ }
218
+ }
219
+
220
+ function collectBuildFiles(standaloneDir: string): CollectedFile[] {
221
+ const files: CollectedFile[] = [];
222
+ const standaloneDotNext = join(standaloneDir, ".next");
223
+
224
+ if (!existsSync(standaloneDotNext)) {
225
+ throw new Error(
226
+ `Standalone build not found at ${standaloneDotNext}.\n` +
227
+ "Make sure your next.config has output: 'standalone'.",
228
+ );
229
+ }
230
+
231
+ // Collect server-side files from .next/standalone/.next/
232
+ // (excluding static/, cache/, diagnostics/ and .nft.json files)
233
+ collectFilesRecursive(
234
+ standaloneDotNext,
235
+ standaloneDotNext,
236
+ ".next",
237
+ EXCLUDED_DIRS,
238
+ files,
239
+ );
240
+
241
+ // Collect public/ files if they exist
242
+ const publicDir = join(standaloneDir, "public");
243
+ if (existsSync(publicDir)) {
244
+ collectFilesRecursive(publicDir, publicDir, "public", new Set(), files);
245
+ }
246
+
247
+ // Collect standalone node_modules (pruned by Next.js, minus dead weight)
248
+ const modulesDir = join(standaloneDir, "node_modules");
249
+ if (existsSync(modulesDir)) {
250
+ collectFilesRecursive(
251
+ modulesDir,
252
+ modulesDir,
253
+ "node_modules",
254
+ EXCLUDED_MODULES,
255
+ files,
256
+ );
257
+ }
258
+
259
+ // Add a minimal package.json for the work directory
260
+ files.push({
261
+ relativePath: "package.json",
262
+ content: Buffer.from('{"type":"commonjs"}'),
263
+ isText: true,
264
+ });
265
+
266
+ return files;
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // Code generation
271
+ // ---------------------------------------------------------------------------
272
+
273
+ function generateServerFile(
274
+ files: CollectedFile[],
275
+ outputPath: string,
276
+ forceColdStart: boolean = false,
277
+ ): void {
278
+ const textEntries: string[] = [];
279
+ const binaryEntries: string[] = [];
280
+
281
+ for (const file of files) {
282
+ const key = JSON.stringify(file.relativePath);
283
+ if (file.isText) {
284
+ const value = JSON.stringify(file.content.toString("utf-8"));
285
+ textEntries.push(` ${key}: ${value},`);
286
+ } else {
287
+ const value = JSON.stringify(file.content.toString("base64"));
288
+ binaryEntries.push(` ${key}: ${value},`);
289
+ }
290
+ }
291
+
292
+ const handlerPreamble = forceColdStart
293
+ ? ` // FORCE_COLD_START: wipe /tmp and handler cache on every request
294
+ clearColdStartCache();
295
+ `
296
+ : "";
297
+
298
+ const code = `"use node";
299
+ /* eslint-disable */
300
+ /* This file is auto-generated by @convex-dev/static-hosting next-build. Do not edit. */
301
+ import { internalActionGeneric } from "convex/server";
302
+ import { v } from "convex/values";
303
+ import { writeFileSync, mkdirSync, existsSync } from "node:fs";
304
+ import { join, dirname } from "node:path";
305
+ import { createRequire } from "node:module";
306
+ import { toReqRes, toFetchResponse } from "fetch-to-node";
307
+ import type { IncomingMessage, ServerResponse } from "node:http";
308
+ ${forceColdStart ? 'import { rmSync } from "node:fs";' : ""}
309
+
310
+ type NodeRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
311
+
312
+ const WORK_DIR = "/tmp/next-app";
313
+
314
+ const BUILD_FILES: Record<string, string> = {
315
+ ${textEntries.join("\n")}
316
+ };
317
+
318
+ const BINARY_FILES: Record<string, string> = {
319
+ ${binaryEntries.join("\n")}
320
+ };
321
+
322
+ let cachedHandler: NodeRequestHandler | null = null;
323
+ ${forceColdStart ? `
324
+ function clearColdStartCache(): void {
325
+ try { rmSync(WORK_DIR, { recursive: true, force: true }); } catch {}
326
+ cachedHandler = null;
327
+ // Clear require cache for /tmp modules
328
+ for (const key of Object.keys(require.cache)) {
329
+ if (key.startsWith(WORK_DIR)) delete require.cache[key];
330
+ }
331
+ }
332
+ ` : ""}
333
+ function ensureFilesWritten(): void {
334
+ if (existsSync(join(WORK_DIR, ".next", "BUILD_ID"))) return;
335
+ const t0 = Date.now();
336
+ for (const [relPath, content] of Object.entries(BUILD_FILES)) {
337
+ const fullPath = join(WORK_DIR, relPath);
338
+ mkdirSync(dirname(fullPath), { recursive: true });
339
+ writeFileSync(fullPath, content);
340
+ }
341
+ for (const [relPath, b64] of Object.entries(BINARY_FILES)) {
342
+ const fullPath = join(WORK_DIR, relPath);
343
+ mkdirSync(dirname(fullPath), { recursive: true });
344
+ writeFileSync(fullPath, Buffer.from(b64, "base64"));
345
+ }
346
+ console.log(\`[next] Wrote \${Object.keys(BUILD_FILES).length + Object.keys(BINARY_FILES).length} files to /tmp in \${Date.now() - t0}ms\`);
347
+ }
348
+
349
+ async function bootNextServer(): Promise<NodeRequestHandler> {
350
+ const t0 = Date.now();
351
+
352
+ // Write all embedded files (app + node_modules) to /tmp
353
+ ensureFilesWritten();
354
+
355
+ const config = JSON.parse(
356
+ BUILD_FILES[".next/required-server-files.json"],
357
+ ).config;
358
+
359
+ process.chdir(WORK_DIR);
360
+ process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(config);
361
+
362
+ // Load NextServer from the embedded node_modules
363
+ const appRequire = createRequire(join(WORK_DIR, "package.json"));
364
+ const NextServer = appRequire("next/dist/server/next-server").default;
365
+ const server = new NextServer({
366
+ dir: WORK_DIR,
367
+ dev: false,
368
+ conf: config,
369
+ });
370
+ await server.prepare();
371
+
372
+ console.log(\`[next] Server ready in \${Date.now() - t0}ms\`);
373
+ return server.getRequestHandler() as NodeRequestHandler;
374
+ }
375
+
376
+ export const handle = internalActionGeneric({
377
+ args: {
378
+ url: v.string(),
379
+ method: v.string(),
380
+ headers: v.array(v.array(v.string())),
381
+ body: v.optional(v.bytes()),
382
+ },
383
+ returns: v.object({
384
+ status: v.number(),
385
+ headers: v.array(v.array(v.string())),
386
+ body: v.bytes(),
387
+ }),
388
+ handler: async (ctx, args) => {
389
+ ${handlerPreamble} if (!cachedHandler) {
390
+ cachedHandler = await bootNextServer();
391
+ }
392
+
393
+ const hasBody = !["GET", "HEAD"].includes(args.method);
394
+ const request = new Request(args.url, {
395
+ method: args.method,
396
+ headers: args.headers as [string, string][],
397
+ body: hasBody && args.body ? args.body : undefined,
398
+ });
399
+
400
+ const { req, res } = toReqRes(request);
401
+ await cachedHandler(req, res);
402
+ if (!res.writableEnded) res.end();
403
+ const response = await toFetchResponse(res);
404
+
405
+ const responseBody = await response.arrayBuffer();
406
+ const responseHeaders: string[][] = [];
407
+ response.headers.forEach((value: string, key: string) => {
408
+ responseHeaders.push([key, value]);
409
+ });
410
+
411
+ return {
412
+ status: response.status,
413
+ headers: responseHeaders,
414
+ body: responseBody,
415
+ };
416
+ },
417
+ });
418
+ `;
419
+
420
+ mkdirSync(dirname(outputPath), { recursive: true });
421
+ writeFileSync(outputPath, code);
422
+ }
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // Static file upload
426
+ // ---------------------------------------------------------------------------
427
+
428
+ let useProd = false;
429
+
430
+ function convexRunAsync(
431
+ functionPath: string,
432
+ args: Record<string, unknown> = {},
433
+ ): Promise<string> {
434
+ return new Promise((resolve, reject) => {
435
+ const cmdArgs = [
436
+ "convex",
437
+ "run",
438
+ functionPath,
439
+ JSON.stringify(args),
440
+ "--typecheck=disable",
441
+ "--codegen=disable",
442
+ ];
443
+ if (useProd) cmdArgs.push("--prod");
444
+ execFile("npx", cmdArgs, { encoding: "utf-8" }, (error, stdout, stderr) => {
445
+ if (error) {
446
+ console.error("Convex run failed:", stderr || stdout);
447
+ reject(error);
448
+ return;
449
+ }
450
+ resolve(stdout.trim());
451
+ });
452
+ });
453
+ }
454
+
455
+ interface StaticFile {
456
+ path: string; // e.g. "/_next/static/chunks/main-xxx.js"
457
+ localPath: string;
458
+ contentType: string;
459
+ }
460
+
461
+ function collectStaticFiles(staticDir: string): StaticFile[] {
462
+ const files: StaticFile[] = [];
463
+
464
+ function walk(dir: string): void {
465
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
466
+ const fullPath = join(dir, entry.name);
467
+ if (entry.isDirectory()) {
468
+ walk(fullPath);
469
+ } else if (entry.isFile()) {
470
+ const relPath = relative(staticDir, fullPath).replace(/\\/g, "/");
471
+ files.push({
472
+ path: `/_next/static/${relPath}`,
473
+ localPath: fullPath,
474
+ contentType: getMimeType(fullPath),
475
+ });
476
+ }
477
+ }
478
+ }
479
+
480
+ walk(staticDir);
481
+ return files;
482
+ }
483
+
484
+ async function uploadSingleFile(
485
+ file: StaticFile,
486
+ componentName: string,
487
+ deploymentId: string,
488
+ ): Promise<void> {
489
+ const content = readFileSync(file.localPath);
490
+
491
+ // Generate upload URL
492
+ const uploadUrlOutput = await convexRunAsync(
493
+ `${componentName}:generateUploadUrl`,
494
+ );
495
+ const uploadUrl = JSON.parse(uploadUrlOutput);
496
+
497
+ // Upload file
498
+ const response = await fetch(uploadUrl, {
499
+ method: "POST",
500
+ headers: { "Content-Type": file.contentType },
501
+ body: content,
502
+ });
503
+
504
+ const { storageId } = (await response.json()) as { storageId: string };
505
+
506
+ // Record the asset
507
+ await convexRunAsync(`${componentName}:recordAsset`, {
508
+ path: file.path,
509
+ storageId,
510
+ contentType: file.contentType,
511
+ deploymentId,
512
+ });
513
+ }
514
+
515
+ async function uploadStaticFiles(
516
+ staticDir: string,
517
+ componentName: string,
518
+ deploymentId: string,
519
+ concurrency: number = 5,
520
+ ): Promise<void> {
521
+ const files = collectStaticFiles(staticDir);
522
+ if (files.length === 0) {
523
+ console.log(" No static files to upload.");
524
+ return;
525
+ }
526
+
527
+ const total = files.length;
528
+ let completed = 0;
529
+ let failed = false;
530
+
531
+ const pending = new Set<Promise<void>>();
532
+ const iterator = files[Symbol.iterator]();
533
+
534
+ function enqueue(): Promise<void> | undefined {
535
+ if (failed) return;
536
+ const next = iterator.next();
537
+ if (next.done) return;
538
+ const file = next.value;
539
+
540
+ const task = uploadSingleFile(file, componentName, deploymentId).then(
541
+ () => {
542
+ completed++;
543
+ console.log(` [${completed}/${total}] ${file.path}`);
544
+ pending.delete(task);
545
+ },
546
+ );
547
+
548
+ task.catch(() => {
549
+ failed = true;
550
+ });
551
+
552
+ pending.add(task);
553
+ return task;
554
+ }
555
+
556
+ // Fill initial pool
557
+ for (let i = 0; i < concurrency && i < total; i++) {
558
+ void enqueue();
559
+ }
560
+
561
+ // Process remaining
562
+ while (pending.size > 0) {
563
+ await Promise.race(pending);
564
+ if (failed) {
565
+ await Promise.allSettled(pending);
566
+ throw new Error("Static file upload failed");
567
+ }
568
+ void enqueue();
569
+ }
570
+
571
+ // Garbage collect old assets and set deployment
572
+ const gcOutput = await convexRunAsync(`${componentName}:gcOldAssets`, {
573
+ currentDeploymentId: deploymentId,
574
+ });
575
+ const gcResult = JSON.parse(gcOutput);
576
+ const deletedCount =
577
+ typeof gcResult === "number" ? gcResult : gcResult.deleted;
578
+ if (deletedCount > 0) {
579
+ console.log(
580
+ ` Cleaned up ${deletedCount} old file(s) from previous deployments`,
581
+ );
582
+ }
583
+ }
584
+
585
+ // ---------------------------------------------------------------------------
586
+ // convex.json configuration
587
+ // ---------------------------------------------------------------------------
588
+
589
+ function ensureConvexJson(convexDir: string): void {
590
+ // Ensure convex.json exists (Convex needs it for the functions directory)
591
+ const projectRoot = resolve(dirname(convexDir));
592
+ const convexJsonPath = join(projectRoot, "convex.json");
593
+
594
+ if (!existsSync(convexJsonPath)) {
595
+ writeFileSync(convexJsonPath, JSON.stringify({ functions: "convex" }, null, 2) + "\n");
596
+ }
597
+ }
598
+
599
+ // ---------------------------------------------------------------------------
600
+ // Main
601
+ // ---------------------------------------------------------------------------
602
+
603
+ async function main(): Promise<void> {
604
+ const args = parseArgs(process.argv.slice(2));
605
+
606
+ if (args.help) {
607
+ showHelp();
608
+ process.exit(0);
609
+ }
610
+
611
+ useProd = args.prod;
612
+ const convexDir = resolve(args.convexDir);
613
+
614
+ // Step 1: Run next build
615
+ if (!args.skipBuild) {
616
+ console.log("Building Next.js app...");
617
+ const buildResult = spawnSync("npx", ["next", "build"], {
618
+ stdio: "inherit",
619
+ });
620
+ if (buildResult.status !== 0) {
621
+ console.error("Next.js build failed.");
622
+ process.exit(1);
623
+ }
624
+ console.log("");
625
+ }
626
+
627
+ // Step 2: Verify standalone output exists
628
+ const standaloneDir = resolve(".next/standalone");
629
+ if (!existsSync(standaloneDir)) {
630
+ console.error(
631
+ 'Error: .next/standalone not found. Make sure next.config has output: "standalone".',
632
+ );
633
+ process.exit(1);
634
+ }
635
+
636
+ // Step 3: Collect build files
637
+ console.log("Collecting build files...");
638
+ const buildFiles = collectBuildFiles(standaloneDir);
639
+ const textCount = buildFiles.filter((f) => f.isText).length;
640
+ const binaryCount = buildFiles.filter((f) => !f.isText).length;
641
+ const totalSize = buildFiles.reduce((sum, f) => sum + f.content.length, 0);
642
+ console.log(
643
+ ` ${buildFiles.length} files (${textCount} text, ${binaryCount} binary, ${(totalSize / 1024).toFixed(0)} KB total)`,
644
+ );
645
+
646
+ // Step 4: Generate the server file
647
+ const outputPath = join(convexDir, "_generatedNextServer.ts");
648
+ console.log(`\nGenerating ${relative(process.cwd(), outputPath)}...`);
649
+ generateServerFile(buildFiles, outputPath, args.forceColdStart);
650
+ if (args.forceColdStart) {
651
+ console.log(" ⚠ FORCE_COLD_START enabled — every request will cold boot");
652
+ }
653
+
654
+ const generatedSize = statSync(outputPath).size;
655
+ console.log(` Generated file: ${(generatedSize / 1024).toFixed(0)} KB`);
656
+
657
+ if (generatedSize > 30 * 1024 * 1024) {
658
+ console.warn(
659
+ "\n WARNING: Generated file exceeds 30 MB. It may exceed Convex's 32 MB bundle limit.",
660
+ );
661
+ console.warn(
662
+ " Consider reducing the number of pages or using dynamic imports.",
663
+ );
664
+ }
665
+
666
+ // Step 5: Ensure convex.json exists
667
+ ensureConvexJson(convexDir);
668
+
669
+ // Step 6: Upload static assets to Convex storage
670
+ if (!args.skipUpload) {
671
+ const envLabel = args.prod ? "production" : "development";
672
+ const deploymentId = randomUUID();
673
+
674
+ let staticDir = join(standaloneDir, ".next", "static");
675
+ if (!existsSync(staticDir)) {
676
+ staticDir = resolve(".next/static");
677
+ }
678
+
679
+ if (existsSync(staticDir)) {
680
+ console.log(`\nUploading static assets to ${envLabel}...`);
681
+ try {
682
+ await uploadStaticFiles(staticDir, args.component, deploymentId);
683
+ console.log("\nStatic assets uploaded.");
684
+ } catch (error) {
685
+ console.error("\nFailed to upload static assets:", error);
686
+ console.error(
687
+ "Make sure your Convex backend is deployed. You can upload later with:",
688
+ );
689
+ console.error(
690
+ " npx @convex-dev/static-hosting next-build --skip-build --prod",
691
+ );
692
+ }
693
+ } else {
694
+ console.log("\nNo static directory found, skipping upload.");
695
+ }
696
+ } else {
697
+ console.log("\nSkipping uploads (--skip-upload).");
698
+ }
699
+
700
+ console.log("\nDone! Next steps:");
701
+ console.log(" npx convex deploy");
702
+ }
703
+
704
+ main().catch((error) => {
705
+ console.error("Error:", error);
706
+ process.exit(1);
707
+ });