@glash/cli 0.2.31 → 0.2.33
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.
- package/README.md +22 -0
- package/bin/glash.mjs +3 -0
- package/commands/deploy.mjs +349 -28
- package/lib/zip.mjs +22 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,6 +68,7 @@ Common Node and frontend targets:
|
|
|
68
68
|
|
|
69
69
|
| Framework or runtime | Notes |
|
|
70
70
|
| --- | --- |
|
|
71
|
+
| glashjs | Native glashDB framework support; the CLI detects `glashjs`/`glash.config.*` and packages build/start scripts when missing |
|
|
71
72
|
| Next.js | Built and started with the Next runtime when no custom start script is present |
|
|
72
73
|
| React | Works with Vite, Create React App, Gatsby, Remix, and custom React servers |
|
|
73
74
|
| Vue | Works with Vue apps and Nuxt projects |
|
|
@@ -93,6 +94,7 @@ Deployment checklist:
|
|
|
93
94
|
- Set required secrets with `glash env:add` or the dashboard before deploying.
|
|
94
95
|
- Make sure the app listens on the expected runtime port.
|
|
95
96
|
- For large `public/` folders, deploy with `--asset-mode offload` to upload public assets to glashDB object storage/CDN and keep the source bundle small.
|
|
97
|
+
- Source bundles over 90 MB automatically use chunked source upload, keeping each request below Cloudflare Free's 100 MB upload limit.
|
|
96
98
|
|
|
97
99
|
Large public folders:
|
|
98
100
|
|
|
@@ -105,6 +107,22 @@ glash deploy --asset-mode auto --asset-threshold 100mb
|
|
|
105
107
|
```
|
|
106
108
|
|
|
107
109
|
Every deploy prints a live monitor link. Open it to watch the black-screen deployment flow while the CLI is packaging, uploading, building, routing, and checking the live URL.
|
|
110
|
+
Public asset offload prepares assets in small batches and uses multipart uploads for large files, so image/video-heavy `public/` folders can upload without forcing the source bundle over the platform limit.
|
|
111
|
+
|
|
112
|
+
Large source bundles:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
# Default: direct upload below 90 MB, chunked upload above it
|
|
116
|
+
glash deploy
|
|
117
|
+
|
|
118
|
+
# Force chunked upload even for a smaller bundle
|
|
119
|
+
glash deploy --source-upload chunked
|
|
120
|
+
|
|
121
|
+
# Tune the auto-chunk threshold
|
|
122
|
+
glash deploy --source-threshold 75mb
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
For glashjs projects, `glash deploy` detects `glashjs` or `glash.config.mjs`, marks the project as `glashjs`, keeps deploy-safe `.glash` output, and adds missing `build` / `start` scripts inside the deployment bundle without editing your local `package.json`.
|
|
108
126
|
|
|
109
127
|
## Commands
|
|
110
128
|
|
|
@@ -122,6 +140,7 @@ Every deploy prints a live monitor link. Open it to watch the black-screen deplo
|
|
|
122
140
|
| `glash open` | Open the linked project's primary domain |
|
|
123
141
|
| `glash deploy [--prod] [--detach]` | Deploy the linked project; streams the pipeline until live |
|
|
124
142
|
| `glash deploy --asset-mode offload` | Upload `public/` assets to object storage/CDN before zipping source |
|
|
143
|
+
| `glash deploy --source-upload chunked` | Stage the source zip through multipart object storage to bypass Cloudflare's single-request cap |
|
|
125
144
|
| `glash deployments` | List recent deployments |
|
|
126
145
|
| `glash status <id>` | Show a deployment's pipeline + events |
|
|
127
146
|
| `glash logs <id>` | Alias of `status` |
|
|
@@ -179,6 +198,9 @@ The commands require PostgreSQL client tools locally: `pg_dump` for dumps,
|
|
|
179
198
|
| `GLASH_APP_URL` | `https://glashdb.com` | Override the dashboard base used for live monitor links |
|
|
180
199
|
| `GLASH_TOKEN` | — | Use this token instead of `~/.glash/credentials.json` (useful in CI) |
|
|
181
200
|
| `GLASH_ASSET_OFFLOAD_THRESHOLD` | `100mb` | Default threshold for `glash deploy --asset-mode auto` |
|
|
201
|
+
| `GLASH_ASSET_BATCH_SIZE` | `50` | Number of public assets prepared per request during `--asset-mode offload` |
|
|
202
|
+
| `GLASH_SOURCE_UPLOAD_MODE` | `auto` | Source upload mode: `auto`, `direct`, or `chunked` |
|
|
203
|
+
| `GLASH_SOURCE_UPLOAD_THRESHOLD` | `90mb` | Default threshold for automatic chunked source upload |
|
|
182
204
|
|
|
183
205
|
## Requirements
|
|
184
206
|
|
package/bin/glash.mjs
CHANGED
|
@@ -100,6 +100,9 @@ ${color.bold('FLAGS')}
|
|
|
100
100
|
--no-env Skip automatic env sync during deploy
|
|
101
101
|
--asset-mode <mode> Public asset handling: inline, auto, offload
|
|
102
102
|
--asset-threshold <size> Auto-offload threshold (default: 100mb)
|
|
103
|
+
--source-upload <mode> Source upload: auto, direct, chunked
|
|
104
|
+
--source-threshold <size> Auto-chunk source threshold (default: 90mb)
|
|
105
|
+
--framework <name> Override framework detection (glashjs, next, vite, etc.)
|
|
103
106
|
--help, -h Show this help
|
|
104
107
|
--version, -v Show CLI version
|
|
105
108
|
|
package/commands/deploy.mjs
CHANGED
|
@@ -118,9 +118,10 @@ async function createNewProjectFromCurrentDirectory() {
|
|
|
118
118
|
const dirName = basename(process.cwd());
|
|
119
119
|
const name = (await prompt(`What's your project's name? [${dirName}] `)) || dirName;
|
|
120
120
|
const slug = name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
121
|
+
const profile = await detectProjectProfile(process.cwd());
|
|
121
122
|
|
|
122
123
|
info(color.dim(`Creating project "${name}"...`));
|
|
123
|
-
const p = await apiPost('/projects', { name, slug });
|
|
124
|
+
const p = await apiPost('/projects', { name, slug, framework: profile.framework });
|
|
124
125
|
await writeProjectLink({ projectId: p.id, slug: p.slug, name: p.name });
|
|
125
126
|
ok(`Created and linked to ${p.name} (${p.slug})`);
|
|
126
127
|
return p.id;
|
|
@@ -135,6 +136,7 @@ export async function deploy({ flags }) {
|
|
|
135
136
|
const gitBranch = flags.branch || currentBranch();
|
|
136
137
|
const target = flags.prod || flags.production ? 'production' : (flags.target || 'production');
|
|
137
138
|
const requestedAssetMode = assetMode(flags);
|
|
139
|
+
const projectProfile = remoteDeploy ? { framework: frameworkHint(flags), label: frameworkHint(flags) || 'auto' } : await detectProjectProfile(process.cwd(), flags);
|
|
138
140
|
const monitorStartedAt = Date.now();
|
|
139
141
|
const initialStatusUrl = deployMonitorUrl({ projectId, after: monitorStartedAt });
|
|
140
142
|
|
|
@@ -150,6 +152,8 @@ export async function deploy({ flags }) {
|
|
|
150
152
|
deploymentId: '',
|
|
151
153
|
target,
|
|
152
154
|
source: remoteDeploy ? 'remote git deploy' : 'local upload',
|
|
155
|
+
framework: projectProfile.framework,
|
|
156
|
+
sourceUpload: { mode: remoteDeploy ? 'remote' : 'pending', parts: 0 },
|
|
153
157
|
packagesUploaded: 0,
|
|
154
158
|
filesIncluded: 0,
|
|
155
159
|
deploymentSizeBytes: 0,
|
|
@@ -200,23 +204,45 @@ export async function deploy({ flags }) {
|
|
|
200
204
|
indicator.setPhase('packaging');
|
|
201
205
|
const zipPath = join(tmpdir(), `glash-deploy-${Date.now()}.zip`);
|
|
202
206
|
try {
|
|
203
|
-
const
|
|
207
|
+
const packageOverrides = await packageOverridesForProfile(process.cwd(), projectProfile);
|
|
208
|
+
const bundle = await zipDirectory(process.cwd(), zipPath, {
|
|
209
|
+
ignore: assetSummary.offloaded ? ['public/**'] : [],
|
|
210
|
+
includeGlashOutput: projectProfile.framework === 'glashjs',
|
|
211
|
+
overrides: packageOverrides,
|
|
212
|
+
});
|
|
204
213
|
const zipStat = await stat(zipPath);
|
|
205
214
|
report.filesIncluded = bundle.fileCount ?? 0;
|
|
206
215
|
report.deploymentSizeBytes = zipStat.size || bundle.bytes || 0;
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
216
|
+
const uploadMeta = {
|
|
217
|
+
commitHash: commitHash || 'local-upload',
|
|
218
|
+
gitBranch: gitBranch || '',
|
|
219
|
+
target,
|
|
220
|
+
framework: projectProfile.framework,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
if (shouldUseChunkedSourceUpload(zipStat.size, flags)) {
|
|
224
|
+
indicator.setPhase('uploading');
|
|
225
|
+
const staged = await uploadSourceBundleStaged(projectId, zipPath, zipStat, uploadMeta, indicator);
|
|
226
|
+
dep = staged.deployment;
|
|
227
|
+
report.source = 'local upload (chunked)';
|
|
228
|
+
report.sourceUpload = { mode: 'chunked', parts: staged.partsUploaded, sha256: staged.sha256 };
|
|
229
|
+
report.packagesUploaded = staged.partsUploaded;
|
|
230
|
+
} else {
|
|
231
|
+
const formData = new FormData();
|
|
232
|
+
formData.append('projectId', projectId);
|
|
233
|
+
formData.append('file', await openAsBlob(zipPath, { type: 'application/zip' }), 'project.zip');
|
|
234
|
+
formData.append('commitHash', uploadMeta.commitHash);
|
|
235
|
+
formData.append('gitBranch', uploadMeta.gitBranch);
|
|
236
|
+
formData.append('target', uploadMeta.target);
|
|
237
|
+
if (uploadMeta.framework) formData.append('framework', uploadMeta.framework);
|
|
238
|
+
|
|
239
|
+
indicator.setPhase('glashing');
|
|
240
|
+
dep = await apiPost('/deployments/upload', formData);
|
|
241
|
+
report.sourceUpload = { mode: 'direct', parts: 1 };
|
|
242
|
+
report.packagesUploaded = 1;
|
|
243
|
+
}
|
|
217
244
|
report.deploymentId = dep?.id ?? '';
|
|
218
245
|
report.statusUrl = deployMonitorUrl({ deploymentId: dep?.id, projectId, after: monitorStartedAt });
|
|
219
|
-
report.packagesUploaded = 1;
|
|
220
246
|
await rm(zipPath, { force: true }).catch(() => {});
|
|
221
247
|
} catch (e) {
|
|
222
248
|
await rm(zipPath, { force: true }).catch(() => {});
|
|
@@ -227,7 +253,7 @@ export async function deploy({ flags }) {
|
|
|
227
253
|
} else {
|
|
228
254
|
indicator.setPhase('hatching');
|
|
229
255
|
try {
|
|
230
|
-
dep = await apiPost('/deployments', { projectId, commitHash, gitBranch, target });
|
|
256
|
+
dep = await apiPost('/deployments', { projectId, commitHash, gitBranch, target, framework: projectProfile.framework });
|
|
231
257
|
report.deploymentId = dep?.id ?? '';
|
|
232
258
|
report.statusUrl = deployMonitorUrl({ deploymentId: dep?.id, projectId, after: monitorStartedAt });
|
|
233
259
|
} catch (e) {
|
|
@@ -370,10 +396,10 @@ async function offloadPublicAssetsBeforeDeploy(projectId, flags) {
|
|
|
370
396
|
|
|
371
397
|
let uploaded = 0;
|
|
372
398
|
let skipped = 0;
|
|
373
|
-
for (const batch of chunk(assets,
|
|
399
|
+
for (const batch of chunk(assets, publicAssetBatchSize(flags))) {
|
|
374
400
|
let prepared;
|
|
375
401
|
try {
|
|
376
|
-
prepared = await apiPost(`/projects/${projectId}/public-assets/prepare`, { assets: batch });
|
|
402
|
+
prepared = await apiPost(`/projects/${projectId}/public-assets/prepare`, { assets: batch }, { timeoutMs: 120_000 });
|
|
377
403
|
} catch (e) {
|
|
378
404
|
throw assetOffloadFailure(e, { mode, status: 'prepare-failed', offloaded: false, count: assets.length, uploaded, skipped, bytes: totalBytes });
|
|
379
405
|
}
|
|
@@ -382,26 +408,20 @@ async function offloadPublicAssetsBeforeDeploy(projectId, flags) {
|
|
|
382
408
|
skipped += 1;
|
|
383
409
|
continue;
|
|
384
410
|
}
|
|
385
|
-
if (!asset.uploadUrl) throw new Error(`No upload URL returned for ${asset.path}`);
|
|
386
411
|
const local = assets.find((item) => item.path === asset.path);
|
|
387
412
|
if (!local) throw new Error(`Prepared unknown asset ${asset.path}`);
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
headers: { 'Content-Type': local.contentType },
|
|
392
|
-
body: blob,
|
|
393
|
-
});
|
|
394
|
-
if (!res.ok) {
|
|
395
|
-
const detail = await res.text().catch(() => '');
|
|
413
|
+
try {
|
|
414
|
+
await uploadPreparedPublicAsset(asset, local);
|
|
415
|
+
} catch (e) {
|
|
396
416
|
throw assetOffloadFailure(
|
|
397
|
-
|
|
417
|
+
e,
|
|
398
418
|
{ mode, status: 'upload-failed', offloaded: false, count: assets.length, uploaded, skipped, bytes: totalBytes },
|
|
399
419
|
);
|
|
400
420
|
}
|
|
401
421
|
uploaded += 1;
|
|
402
422
|
}
|
|
403
423
|
try {
|
|
404
|
-
await apiPost(`/projects/${projectId}/public-assets/complete`, { assets: batch });
|
|
424
|
+
await apiPost(`/projects/${projectId}/public-assets/complete`, { assets: batch }, { timeoutMs: 120_000 });
|
|
405
425
|
} catch (e) {
|
|
406
426
|
throw assetOffloadFailure(e, { mode, status: 'complete-failed', offloaded: false, count: assets.length, uploaded, skipped, bytes: totalBytes });
|
|
407
427
|
}
|
|
@@ -418,6 +438,200 @@ async function offloadPublicAssetsBeforeDeploy(projectId, flags) {
|
|
|
418
438
|
};
|
|
419
439
|
}
|
|
420
440
|
|
|
441
|
+
async function uploadPreparedPublicAsset(asset, local) {
|
|
442
|
+
if (asset.uploadKind === 'multipart' || asset.uploadPartUrl || asset.completeMultipartUrl) {
|
|
443
|
+
return uploadMultipartPublicAsset(asset, local);
|
|
444
|
+
}
|
|
445
|
+
if (!asset.uploadUrl) throw new Error(`No upload URL returned for ${asset.path}`);
|
|
446
|
+
const blob = await openAsBlob(join(process.cwd(), 'public', asset.path), { type: local.contentType });
|
|
447
|
+
const res = await fetchWithRetries(asset.uploadUrl, {
|
|
448
|
+
method: asset.method || 'PUT',
|
|
449
|
+
headers: { 'Content-Type': local.contentType },
|
|
450
|
+
body: blob,
|
|
451
|
+
}, { timeoutMs: uploadTimeoutMsFor(local.sizeBytes) });
|
|
452
|
+
if (!res.ok) {
|
|
453
|
+
const detail = await res.text().catch(() => '');
|
|
454
|
+
throw new Error(`Upload ${asset.path} failed with HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function uploadMultipartPublicAsset(asset, local) {
|
|
459
|
+
if (!asset.uploadPartUrl || !asset.completeMultipartUrl || !asset.uploadId) {
|
|
460
|
+
throw new Error(`Multipart upload details missing for ${asset.path}`);
|
|
461
|
+
}
|
|
462
|
+
const partSize = publicAssetPartSize(asset.partSize);
|
|
463
|
+
const blob = await openAsBlob(join(process.cwd(), 'public', asset.path), { type: local.contentType });
|
|
464
|
+
const parts = [];
|
|
465
|
+
let partNumber = 1;
|
|
466
|
+
for (let offset = 0; offset < local.sizeBytes; offset += partSize) {
|
|
467
|
+
const end = Math.min(offset + partSize, local.sizeBytes);
|
|
468
|
+
const partUrl = withQueryParam(asset.uploadPartUrl, 'partNumber', String(partNumber));
|
|
469
|
+
const res = await fetchWithRetries(partUrl, {
|
|
470
|
+
method: 'PUT',
|
|
471
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
472
|
+
body: blob.slice(offset, end, 'application/octet-stream'),
|
|
473
|
+
}, { timeoutMs: uploadTimeoutMsFor(end - offset) });
|
|
474
|
+
if (!res.ok) {
|
|
475
|
+
const detail = await res.text().catch(() => '');
|
|
476
|
+
throw new Error(`Upload ${asset.path} part ${partNumber} failed with HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
|
|
477
|
+
}
|
|
478
|
+
let data;
|
|
479
|
+
try { data = await res.json(); } catch { data = {}; }
|
|
480
|
+
const part = data?.part;
|
|
481
|
+
if (!part?.etag || !Number.isInteger(Number(part.partNumber))) {
|
|
482
|
+
throw new Error(`Upload ${asset.path} part ${partNumber} did not return a multipart ETag`);
|
|
483
|
+
}
|
|
484
|
+
parts.push({ partNumber: Number(part.partNumber), etag: String(part.etag) });
|
|
485
|
+
partNumber += 1;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const complete = await fetchWithRetries(asset.completeMultipartUrl, {
|
|
489
|
+
method: 'POST',
|
|
490
|
+
headers: { 'Content-Type': 'application/json' },
|
|
491
|
+
body: JSON.stringify({ parts }),
|
|
492
|
+
}, { timeoutMs: 120_000 });
|
|
493
|
+
if (!complete.ok) {
|
|
494
|
+
const detail = await complete.text().catch(() => '');
|
|
495
|
+
throw new Error(`Completing multipart upload for ${asset.path} failed with HTTP ${complete.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function uploadSourceBundleStaged(projectId, zipPath, zipStat, meta, indicator) {
|
|
500
|
+
const sizeBytes = zipStat.size;
|
|
501
|
+
const sha256 = await hashFile(zipPath);
|
|
502
|
+
let prepared;
|
|
503
|
+
try {
|
|
504
|
+
prepared = await apiPost('/deployments/source-upload/prepare', {
|
|
505
|
+
projectId,
|
|
506
|
+
fileName: 'project.zip',
|
|
507
|
+
sizeBytes,
|
|
508
|
+
sha256,
|
|
509
|
+
contentType: 'application/zip',
|
|
510
|
+
framework: meta.framework,
|
|
511
|
+
}, { timeoutMs: 120_000 });
|
|
512
|
+
} catch (e) {
|
|
513
|
+
throw sourceUploadFailure(e);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (!prepared?.uploadPartUrl || !prepared?.completeUrl || !prepared?.uploadId) {
|
|
517
|
+
throw new Error('Chunked source upload was not prepared by this GlashDB API.');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const partSize = sourceUploadPartSize(prepared.partSize);
|
|
521
|
+
const blob = await openAsBlob(zipPath, { type: 'application/zip' });
|
|
522
|
+
const parts = [];
|
|
523
|
+
let partNumber = 1;
|
|
524
|
+
for (let offset = 0; offset < sizeBytes; offset += partSize) {
|
|
525
|
+
const end = Math.min(offset + partSize, sizeBytes);
|
|
526
|
+
const partUrl = withQueryParam(prepared.uploadPartUrl, 'partNumber', String(partNumber));
|
|
527
|
+
const res = await fetchWithRetries(partUrl, {
|
|
528
|
+
method: 'PUT',
|
|
529
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
530
|
+
body: blob.slice(offset, end, 'application/octet-stream'),
|
|
531
|
+
}, { timeoutMs: uploadTimeoutMsFor(end - offset) });
|
|
532
|
+
if (!res.ok) {
|
|
533
|
+
const detail = await res.text().catch(() => '');
|
|
534
|
+
throw new Error(`Chunked source upload part ${partNumber} failed with HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
|
|
535
|
+
}
|
|
536
|
+
let data;
|
|
537
|
+
try { data = await res.json(); } catch { data = {}; }
|
|
538
|
+
const part = data?.part;
|
|
539
|
+
if (!part?.etag || !Number.isInteger(Number(part.partNumber))) {
|
|
540
|
+
throw new Error(`Chunked source upload part ${partNumber} did not return a multipart ETag`);
|
|
541
|
+
}
|
|
542
|
+
parts.push({ partNumber: Number(part.partNumber), etag: String(part.etag) });
|
|
543
|
+
const percent = 28 + Math.round((end / sizeBytes) * 14);
|
|
544
|
+
indicator?.setProgress?.(percent, 'uploading');
|
|
545
|
+
partNumber += 1;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const completed = await fetchWithRetries(prepared.completeUrl, {
|
|
549
|
+
method: 'POST',
|
|
550
|
+
headers: { 'Content-Type': 'application/json' },
|
|
551
|
+
body: JSON.stringify({ parts }),
|
|
552
|
+
}, { timeoutMs: 120_000 });
|
|
553
|
+
if (!completed.ok) {
|
|
554
|
+
const detail = await completed.text().catch(() => '');
|
|
555
|
+
throw new Error(`Completing chunked source upload failed with HTTP ${completed.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
|
|
556
|
+
}
|
|
557
|
+
let completion;
|
|
558
|
+
try { completion = await completed.json(); } catch { completion = {}; }
|
|
559
|
+
if (!completion?.sourceUrl) {
|
|
560
|
+
throw new Error('Chunked source upload completed without a staged source URL.');
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
indicator?.setProgress?.(43, 'hatching');
|
|
564
|
+
const deployment = await apiPost('/deployments/source-upload/commit', {
|
|
565
|
+
projectId,
|
|
566
|
+
sourceUrl: completion.sourceUrl,
|
|
567
|
+
sha256,
|
|
568
|
+
sizeBytes: String(sizeBytes),
|
|
569
|
+
commitHash: meta.commitHash,
|
|
570
|
+
gitBranch: meta.gitBranch,
|
|
571
|
+
target: meta.target,
|
|
572
|
+
framework: meta.framework,
|
|
573
|
+
}, { timeoutMs: 120_000 });
|
|
574
|
+
|
|
575
|
+
return { deployment, partsUploaded: parts.length, sha256 };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function sourceUploadFailure(error) {
|
|
579
|
+
const message = error?.message || String(error);
|
|
580
|
+
if (error?.status === 404 && /source-upload|Cannot POST/i.test(message)) {
|
|
581
|
+
return new Error('This GlashDB edge does not support chunked source upload yet. Deploy the router/backend source-upload update, then retry.');
|
|
582
|
+
}
|
|
583
|
+
if (error?.status === 503 && /storage is not configured/i.test(message)) {
|
|
584
|
+
return new Error('Chunked source upload storage is not configured on this GlashDB API.');
|
|
585
|
+
}
|
|
586
|
+
return error instanceof Error ? error : new Error(message);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function fetchWithRetries(url, init, { retries = 3, timeoutMs = 120_000 } = {}) {
|
|
590
|
+
let attempt = 0;
|
|
591
|
+
// eslint-disable-next-line no-constant-condition
|
|
592
|
+
while (true) {
|
|
593
|
+
const ctrl = new AbortController();
|
|
594
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
595
|
+
try {
|
|
596
|
+
const res = await fetch(url, { ...init, signal: ctrl.signal });
|
|
597
|
+
clearTimeout(timer);
|
|
598
|
+
if (res.ok || !isRetryableUploadStatus(res.status) || attempt >= retries) return res;
|
|
599
|
+
await res.body?.cancel?.().catch(() => {});
|
|
600
|
+
} catch (error) {
|
|
601
|
+
clearTimeout(timer);
|
|
602
|
+
if (attempt >= retries) throw error;
|
|
603
|
+
}
|
|
604
|
+
await sleep(uploadBackoffMs(attempt));
|
|
605
|
+
attempt += 1;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function isRetryableUploadStatus(status) {
|
|
610
|
+
return [408, 425, 429, 500, 502, 503, 504, 522, 524].includes(Number(status));
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function uploadBackoffMs(attempt) {
|
|
614
|
+
const base = Math.min(1000 * 2 ** attempt, 10000);
|
|
615
|
+
return Math.round(base * (0.75 + Math.random() * 0.5));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function withQueryParam(url, key, value) {
|
|
619
|
+
const next = new URL(url);
|
|
620
|
+
next.searchParams.set(key, value);
|
|
621
|
+
return next.toString();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function uploadTimeoutMsFor(bytes) {
|
|
625
|
+
const size = Number(bytes) || 0;
|
|
626
|
+
return Math.max(120_000, Math.min(15 * 60_000, Math.ceil(size / (1024 * 1024)) * 15_000));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function publicAssetPartSize(value) {
|
|
630
|
+
const parsed = Number(value);
|
|
631
|
+
if (Number.isFinite(parsed) && parsed >= 5 * 1024 * 1024) return Math.round(parsed);
|
|
632
|
+
return 16 * 1024 * 1024;
|
|
633
|
+
}
|
|
634
|
+
|
|
421
635
|
function assetOffloadFailure(error, assetSummary) {
|
|
422
636
|
const message = friendlyAssetOffloadError(error);
|
|
423
637
|
const wrapped = new Error(message);
|
|
@@ -444,6 +658,93 @@ function assetMode(flags) {
|
|
|
444
658
|
throw new Error(`Unsupported --asset-mode "${raw}". Use inline, auto, or offload.`);
|
|
445
659
|
}
|
|
446
660
|
|
|
661
|
+
function sourceUploadMode(flags) {
|
|
662
|
+
const raw = String(flags['source-upload'] ?? process.env.GLASH_SOURCE_UPLOAD_MODE ?? 'auto').toLowerCase();
|
|
663
|
+
if (['auto', 'direct', 'chunked'].includes(raw)) return raw;
|
|
664
|
+
throw new Error(`Unsupported --source-upload "${raw}". Use auto, direct, or chunked.`);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function shouldUseChunkedSourceUpload(sizeBytes, flags) {
|
|
668
|
+
const mode = sourceUploadMode(flags);
|
|
669
|
+
if (mode === 'direct') return false;
|
|
670
|
+
if (mode === 'chunked') return true;
|
|
671
|
+
return Number(sizeBytes) >= sourceUploadThresholdBytes(flags);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function sourceUploadThresholdBytes(flags) {
|
|
675
|
+
return parseByteSize(flags['source-threshold'] ?? process.env.GLASH_SOURCE_UPLOAD_THRESHOLD ?? '90mb');
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function sourceUploadPartSize(value) {
|
|
679
|
+
const parsed = Number(value);
|
|
680
|
+
if (Number.isFinite(parsed) && parsed >= 5 * 1024 * 1024) return Math.round(parsed);
|
|
681
|
+
return 16 * 1024 * 1024;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function detectProjectProfile(cwd, flags = {}) {
|
|
685
|
+
const forced = frameworkHint(flags);
|
|
686
|
+
if (forced) return { framework: forced, label: forced };
|
|
687
|
+
const pkg = await readPackageJson(cwd);
|
|
688
|
+
const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
689
|
+
const hasGlashConfig = await existsAny(cwd, ['glash.config.mjs', 'glash.config.js', 'glash.config.cjs']);
|
|
690
|
+
if (deps.glashjs || hasGlashConfig) return { framework: 'glashjs', label: 'glashjs' };
|
|
691
|
+
if (deps.next) return { framework: 'next', label: 'Next.js' };
|
|
692
|
+
if (deps.nuxt || deps.nuxt3) return { framework: 'nuxt', label: 'Nuxt' };
|
|
693
|
+
if (deps.vite) return { framework: 'vite', label: 'Vite' };
|
|
694
|
+
if (deps['@remix-run/dev'] || deps.remix) return { framework: 'remix', label: 'Remix' };
|
|
695
|
+
if (deps.astro) return { framework: 'astro', label: 'Astro' };
|
|
696
|
+
if (deps.svelte || deps['@sveltejs/kit']) return { framework: 'sveltekit', label: 'SvelteKit' };
|
|
697
|
+
if (deps.express || deps.fastify || deps['@nestjs/core']) return { framework: 'node', label: 'Node.js' };
|
|
698
|
+
if (pkg) return { framework: 'node', label: 'Node.js' };
|
|
699
|
+
return { framework: 'static', label: 'Static' };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function packageOverridesForProfile(cwd, profile) {
|
|
703
|
+
if (profile?.framework !== 'glashjs') return {};
|
|
704
|
+
const pkg = await readPackageJson(cwd);
|
|
705
|
+
if (!pkg) return {};
|
|
706
|
+
const scripts = { ...(pkg.scripts ?? {}) };
|
|
707
|
+
let changed = false;
|
|
708
|
+
if (!scripts.build) {
|
|
709
|
+
scripts.build = 'glash build';
|
|
710
|
+
changed = true;
|
|
711
|
+
}
|
|
712
|
+
if (!scripts.start) {
|
|
713
|
+
scripts.start = 'glash serve --port ${PORT:-3000}';
|
|
714
|
+
changed = true;
|
|
715
|
+
}
|
|
716
|
+
if (!changed) return {};
|
|
717
|
+
return {
|
|
718
|
+
'package.json': `${JSON.stringify({ ...pkg, scripts }, null, 2)}\n`,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function frameworkHint(flags = {}) {
|
|
723
|
+
const raw = String(flags.framework ?? flags.runtime ?? '').trim().toLowerCase();
|
|
724
|
+
if (!raw) return '';
|
|
725
|
+
const aliases = { nextjs: 'next', glash: 'glashjs', glashdb: 'glashjs', react: 'vite' };
|
|
726
|
+
const normalized = aliases[raw] ?? raw;
|
|
727
|
+
return /^[a-z0-9._-]{1,40}$/.test(normalized) ? normalized : '';
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function readPackageJson(cwd) {
|
|
731
|
+
try {
|
|
732
|
+
return JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8'));
|
|
733
|
+
} catch {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async function existsAny(cwd, files) {
|
|
739
|
+
for (const file of files) {
|
|
740
|
+
try {
|
|
741
|
+
await access(join(cwd, file));
|
|
742
|
+
return true;
|
|
743
|
+
} catch {}
|
|
744
|
+
}
|
|
745
|
+
return false;
|
|
746
|
+
}
|
|
747
|
+
|
|
447
748
|
async function publicAssetFiles(cwd) {
|
|
448
749
|
const entries = await fg('public/**/*', {
|
|
449
750
|
cwd,
|
|
@@ -511,11 +812,18 @@ function assetOffloadThresholdBytes(flags) {
|
|
|
511
812
|
return parseByteSize(flags['asset-threshold'] ?? process.env.GLASH_ASSET_OFFLOAD_THRESHOLD ?? '100mb');
|
|
512
813
|
}
|
|
513
814
|
|
|
815
|
+
function publicAssetBatchSize(flags) {
|
|
816
|
+
const raw = flags['asset-batch-size'] ?? process.env.GLASH_ASSET_BATCH_SIZE ?? '50';
|
|
817
|
+
const parsed = Number(raw);
|
|
818
|
+
if (!Number.isFinite(parsed) || parsed < 1) return 50;
|
|
819
|
+
return Math.max(1, Math.min(250, Math.floor(parsed)));
|
|
820
|
+
}
|
|
821
|
+
|
|
514
822
|
function parseByteSize(value) {
|
|
515
823
|
if (typeof value === 'number') return value;
|
|
516
824
|
const raw = String(value || '').trim().toLowerCase();
|
|
517
825
|
const match = raw.match(/^(\d+(?:\.\d+)?)\s*(b|kb|kib|mb|mib|gb|gib)?$/);
|
|
518
|
-
if (!match) throw new Error(`Invalid
|
|
826
|
+
if (!match) throw new Error(`Invalid size "${value}"`);
|
|
519
827
|
const amount = Number(match[1]);
|
|
520
828
|
const unit = match[2] || 'b';
|
|
521
829
|
const multiplier = unit === 'gb' || unit === 'gib'
|
|
@@ -700,6 +1008,7 @@ function printDeployReport(report) {
|
|
|
700
1008
|
['Deployment ID', report.deploymentId || '-'],
|
|
701
1009
|
['Target', report.target || 'production'],
|
|
702
1010
|
['Source', report.source || '-'],
|
|
1011
|
+
['Framework', report.framework || 'auto'],
|
|
703
1012
|
['Packages uploaded', formatPackagesUploaded(report)],
|
|
704
1013
|
['Public assets', formatAssetSummary(report.assetSummary)],
|
|
705
1014
|
['Deployment size', formatDeploymentSize(report)],
|
|
@@ -728,6 +1037,14 @@ function formatReportStatus(status) {
|
|
|
728
1037
|
|
|
729
1038
|
function formatPackagesUploaded(report) {
|
|
730
1039
|
const count = report.packagesUploaded ?? 0;
|
|
1040
|
+
if (report.sourceUpload?.mode === 'chunked') {
|
|
1041
|
+
const files = report.filesIncluded ? ` (${report.filesIncluded.toLocaleString()} files included)` : '';
|
|
1042
|
+
return `${count.toLocaleString()} source chunk${count === 1 ? '' : 's'}${files}`;
|
|
1043
|
+
}
|
|
1044
|
+
if (report.sourceUpload?.mode === 'direct') {
|
|
1045
|
+
const files = report.filesIncluded ? ` (${report.filesIncluded.toLocaleString()} files included)` : '';
|
|
1046
|
+
return `1 source bundle${files}`;
|
|
1047
|
+
}
|
|
731
1048
|
const label = count === 1 ? 'source bundle' : 'source bundles';
|
|
732
1049
|
const files = report.filesIncluded
|
|
733
1050
|
? ` (${report.filesIncluded.toLocaleString()} files included)`
|
|
@@ -844,6 +1161,9 @@ function createDeployIndicator() {
|
|
|
844
1161
|
if (!nextPhase) return;
|
|
845
1162
|
setProgress(progressFromPhase(nextPhase), nextPhase);
|
|
846
1163
|
},
|
|
1164
|
+
setProgress(nextProgress, nextPhase = phase) {
|
|
1165
|
+
setProgress(nextProgress, nextPhase);
|
|
1166
|
+
},
|
|
847
1167
|
finish(finalPhase = 'complete') {
|
|
848
1168
|
stopTimer();
|
|
849
1169
|
phase = finalPhase;
|
|
@@ -873,6 +1193,7 @@ function progressFromPhase(phase) {
|
|
|
873
1193
|
syncing: 8,
|
|
874
1194
|
assets: 15,
|
|
875
1195
|
packaging: 22,
|
|
1196
|
+
uploading: 28,
|
|
876
1197
|
glashing: 35,
|
|
877
1198
|
hatching: 45,
|
|
878
1199
|
waiting: 48,
|
package/lib/zip.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { createWriteStream } from 'node:fs';
|
|
|
4
4
|
import { join, relative } from 'node:path';
|
|
5
5
|
import fs from 'fs-extra';
|
|
6
6
|
|
|
7
|
-
export async function zipDirectory(sourceDir, outPath, { ignore = [] } = {}) {
|
|
7
|
+
export async function zipDirectory(sourceDir, outPath, { ignore = [], includeGlashOutput = false, overrides = {} } = {}) {
|
|
8
8
|
// Default ignores
|
|
9
9
|
const defaultIgnore = [
|
|
10
10
|
'node_modules/**',
|
|
@@ -32,7 +32,7 @@ export async function zipDirectory(sourceDir, outPath, { ignore = [] } = {}) {
|
|
|
32
32
|
'build/**',
|
|
33
33
|
'**/build/**',
|
|
34
34
|
'out/**',
|
|
35
|
-
'**/out/**',
|
|
35
|
+
...(includeGlashOutput ? [] : ['**/out/**']),
|
|
36
36
|
'coverage/**',
|
|
37
37
|
'**/coverage/**',
|
|
38
38
|
'.turbo/**',
|
|
@@ -43,8 +43,19 @@ export async function zipDirectory(sourceDir, outPath, { ignore = [] } = {}) {
|
|
|
43
43
|
'**/.parcel-cache/**',
|
|
44
44
|
'.vite/**',
|
|
45
45
|
'**/.vite/**',
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
...(includeGlashOutput
|
|
47
|
+
? [
|
|
48
|
+
'.glash/project.json',
|
|
49
|
+
'**/.glash/project.json',
|
|
50
|
+
'.glash/credentials.json',
|
|
51
|
+
'**/.glash/credentials.json',
|
|
52
|
+
'.glash/cache/**',
|
|
53
|
+
'**/.glash/cache/**',
|
|
54
|
+
]
|
|
55
|
+
: [
|
|
56
|
+
'.glash/**',
|
|
57
|
+
'**/.glash/**',
|
|
58
|
+
]),
|
|
48
59
|
'*.log',
|
|
49
60
|
'**/*.log',
|
|
50
61
|
'npm-debug.log*',
|
|
@@ -63,20 +74,21 @@ export async function zipDirectory(sourceDir, outPath, { ignore = [] } = {}) {
|
|
|
63
74
|
'**/.env.development',
|
|
64
75
|
];
|
|
65
76
|
const allIgnore = [...new Set([...defaultIgnore, ...loadIgnorePatterns(sourceDir), ...ignore])];
|
|
66
|
-
const
|
|
77
|
+
const overridePaths = new Set(Object.keys(overrides));
|
|
78
|
+
const files = (await fg('**/*', {
|
|
67
79
|
cwd: sourceDir,
|
|
68
80
|
ignore: allIgnore,
|
|
69
81
|
dot: true,
|
|
70
82
|
onlyFiles: true,
|
|
71
83
|
followSymbolicLinks: false,
|
|
72
|
-
});
|
|
84
|
+
})).filter((file) => !overridePaths.has(file));
|
|
73
85
|
|
|
74
86
|
return new Promise((resolve, reject) => {
|
|
75
87
|
const zip = new yazl.ZipFile();
|
|
76
88
|
const output = createWriteStream(outPath);
|
|
77
89
|
let bytes = 0;
|
|
78
90
|
|
|
79
|
-
output.on('close', () => resolve({ bytes, fileCount: files.length, files }));
|
|
91
|
+
output.on('close', () => resolve({ bytes, fileCount: files.length + overridePaths.size, files: [...files, ...overridePaths] }));
|
|
80
92
|
output.on('error', reject);
|
|
81
93
|
zip.outputStream.on('data', (chunk) => { bytes += chunk.length; });
|
|
82
94
|
zip.outputStream.on('error', reject);
|
|
@@ -85,6 +97,9 @@ export async function zipDirectory(sourceDir, outPath, { ignore = [] } = {}) {
|
|
|
85
97
|
for (const file of files) {
|
|
86
98
|
zip.addFile(join(sourceDir, file), file);
|
|
87
99
|
}
|
|
100
|
+
for (const [file, content] of Object.entries(overrides)) {
|
|
101
|
+
zip.addBuffer(Buffer.isBuffer(content) ? content : Buffer.from(String(content)), file);
|
|
102
|
+
}
|
|
88
103
|
zip.end();
|
|
89
104
|
});
|
|
90
105
|
}
|
package/package.json
CHANGED