@glash/cli 0.2.31 → 0.2.32
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 +2 -0
- package/commands/deploy.mjs +118 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -105,6 +105,7 @@ glash deploy --asset-mode auto --asset-threshold 100mb
|
|
|
105
105
|
```
|
|
106
106
|
|
|
107
107
|
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.
|
|
108
|
+
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.
|
|
108
109
|
|
|
109
110
|
## Commands
|
|
110
111
|
|
|
@@ -179,6 +180,7 @@ The commands require PostgreSQL client tools locally: `pg_dump` for dumps,
|
|
|
179
180
|
| `GLASH_APP_URL` | `https://glashdb.com` | Override the dashboard base used for live monitor links |
|
|
180
181
|
| `GLASH_TOKEN` | — | Use this token instead of `~/.glash/credentials.json` (useful in CI) |
|
|
181
182
|
| `GLASH_ASSET_OFFLOAD_THRESHOLD` | `100mb` | Default threshold for `glash deploy --asset-mode auto` |
|
|
183
|
+
| `GLASH_ASSET_BATCH_SIZE` | `50` | Number of public assets prepared per request during `--asset-mode offload` |
|
|
182
184
|
|
|
183
185
|
## Requirements
|
|
184
186
|
|
package/commands/deploy.mjs
CHANGED
|
@@ -370,10 +370,10 @@ async function offloadPublicAssetsBeforeDeploy(projectId, flags) {
|
|
|
370
370
|
|
|
371
371
|
let uploaded = 0;
|
|
372
372
|
let skipped = 0;
|
|
373
|
-
for (const batch of chunk(assets,
|
|
373
|
+
for (const batch of chunk(assets, publicAssetBatchSize(flags))) {
|
|
374
374
|
let prepared;
|
|
375
375
|
try {
|
|
376
|
-
prepared = await apiPost(`/projects/${projectId}/public-assets/prepare`, { assets: batch });
|
|
376
|
+
prepared = await apiPost(`/projects/${projectId}/public-assets/prepare`, { assets: batch }, { timeoutMs: 120_000 });
|
|
377
377
|
} catch (e) {
|
|
378
378
|
throw assetOffloadFailure(e, { mode, status: 'prepare-failed', offloaded: false, count: assets.length, uploaded, skipped, bytes: totalBytes });
|
|
379
379
|
}
|
|
@@ -382,26 +382,20 @@ async function offloadPublicAssetsBeforeDeploy(projectId, flags) {
|
|
|
382
382
|
skipped += 1;
|
|
383
383
|
continue;
|
|
384
384
|
}
|
|
385
|
-
if (!asset.uploadUrl) throw new Error(`No upload URL returned for ${asset.path}`);
|
|
386
385
|
const local = assets.find((item) => item.path === asset.path);
|
|
387
386
|
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(() => '');
|
|
387
|
+
try {
|
|
388
|
+
await uploadPreparedPublicAsset(asset, local);
|
|
389
|
+
} catch (e) {
|
|
396
390
|
throw assetOffloadFailure(
|
|
397
|
-
|
|
391
|
+
e,
|
|
398
392
|
{ mode, status: 'upload-failed', offloaded: false, count: assets.length, uploaded, skipped, bytes: totalBytes },
|
|
399
393
|
);
|
|
400
394
|
}
|
|
401
395
|
uploaded += 1;
|
|
402
396
|
}
|
|
403
397
|
try {
|
|
404
|
-
await apiPost(`/projects/${projectId}/public-assets/complete`, { assets: batch });
|
|
398
|
+
await apiPost(`/projects/${projectId}/public-assets/complete`, { assets: batch }, { timeoutMs: 120_000 });
|
|
405
399
|
} catch (e) {
|
|
406
400
|
throw assetOffloadFailure(e, { mode, status: 'complete-failed', offloaded: false, count: assets.length, uploaded, skipped, bytes: totalBytes });
|
|
407
401
|
}
|
|
@@ -418,6 +412,110 @@ async function offloadPublicAssetsBeforeDeploy(projectId, flags) {
|
|
|
418
412
|
};
|
|
419
413
|
}
|
|
420
414
|
|
|
415
|
+
async function uploadPreparedPublicAsset(asset, local) {
|
|
416
|
+
if (asset.uploadKind === 'multipart' || asset.uploadPartUrl || asset.completeMultipartUrl) {
|
|
417
|
+
return uploadMultipartPublicAsset(asset, local);
|
|
418
|
+
}
|
|
419
|
+
if (!asset.uploadUrl) throw new Error(`No upload URL returned for ${asset.path}`);
|
|
420
|
+
const blob = await openAsBlob(join(process.cwd(), 'public', asset.path), { type: local.contentType });
|
|
421
|
+
const res = await fetchWithRetries(asset.uploadUrl, {
|
|
422
|
+
method: asset.method || 'PUT',
|
|
423
|
+
headers: { 'Content-Type': local.contentType },
|
|
424
|
+
body: blob,
|
|
425
|
+
}, { timeoutMs: uploadTimeoutMsFor(local.sizeBytes) });
|
|
426
|
+
if (!res.ok) {
|
|
427
|
+
const detail = await res.text().catch(() => '');
|
|
428
|
+
throw new Error(`Upload ${asset.path} failed with HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function uploadMultipartPublicAsset(asset, local) {
|
|
433
|
+
if (!asset.uploadPartUrl || !asset.completeMultipartUrl || !asset.uploadId) {
|
|
434
|
+
throw new Error(`Multipart upload details missing for ${asset.path}`);
|
|
435
|
+
}
|
|
436
|
+
const partSize = publicAssetPartSize(asset.partSize);
|
|
437
|
+
const blob = await openAsBlob(join(process.cwd(), 'public', asset.path), { type: local.contentType });
|
|
438
|
+
const parts = [];
|
|
439
|
+
let partNumber = 1;
|
|
440
|
+
for (let offset = 0; offset < local.sizeBytes; offset += partSize) {
|
|
441
|
+
const end = Math.min(offset + partSize, local.sizeBytes);
|
|
442
|
+
const partUrl = withQueryParam(asset.uploadPartUrl, 'partNumber', String(partNumber));
|
|
443
|
+
const res = await fetchWithRetries(partUrl, {
|
|
444
|
+
method: 'PUT',
|
|
445
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
446
|
+
body: blob.slice(offset, end, 'application/octet-stream'),
|
|
447
|
+
}, { timeoutMs: uploadTimeoutMsFor(end - offset) });
|
|
448
|
+
if (!res.ok) {
|
|
449
|
+
const detail = await res.text().catch(() => '');
|
|
450
|
+
throw new Error(`Upload ${asset.path} part ${partNumber} failed with HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
|
|
451
|
+
}
|
|
452
|
+
let data;
|
|
453
|
+
try { data = await res.json(); } catch { data = {}; }
|
|
454
|
+
const part = data?.part;
|
|
455
|
+
if (!part?.etag || !Number.isInteger(Number(part.partNumber))) {
|
|
456
|
+
throw new Error(`Upload ${asset.path} part ${partNumber} did not return a multipart ETag`);
|
|
457
|
+
}
|
|
458
|
+
parts.push({ partNumber: Number(part.partNumber), etag: String(part.etag) });
|
|
459
|
+
partNumber += 1;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const complete = await fetchWithRetries(asset.completeMultipartUrl, {
|
|
463
|
+
method: 'POST',
|
|
464
|
+
headers: { 'Content-Type': 'application/json' },
|
|
465
|
+
body: JSON.stringify({ parts }),
|
|
466
|
+
}, { timeoutMs: 120_000 });
|
|
467
|
+
if (!complete.ok) {
|
|
468
|
+
const detail = await complete.text().catch(() => '');
|
|
469
|
+
throw new Error(`Completing multipart upload for ${asset.path} failed with HTTP ${complete.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function fetchWithRetries(url, init, { retries = 3, timeoutMs = 120_000 } = {}) {
|
|
474
|
+
let attempt = 0;
|
|
475
|
+
// eslint-disable-next-line no-constant-condition
|
|
476
|
+
while (true) {
|
|
477
|
+
const ctrl = new AbortController();
|
|
478
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
479
|
+
try {
|
|
480
|
+
const res = await fetch(url, { ...init, signal: ctrl.signal });
|
|
481
|
+
clearTimeout(timer);
|
|
482
|
+
if (res.ok || !isRetryableUploadStatus(res.status) || attempt >= retries) return res;
|
|
483
|
+
await res.body?.cancel?.().catch(() => {});
|
|
484
|
+
} catch (error) {
|
|
485
|
+
clearTimeout(timer);
|
|
486
|
+
if (attempt >= retries) throw error;
|
|
487
|
+
}
|
|
488
|
+
await sleep(uploadBackoffMs(attempt));
|
|
489
|
+
attempt += 1;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function isRetryableUploadStatus(status) {
|
|
494
|
+
return [408, 425, 429, 500, 502, 503, 504, 522, 524].includes(Number(status));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function uploadBackoffMs(attempt) {
|
|
498
|
+
const base = Math.min(1000 * 2 ** attempt, 10000);
|
|
499
|
+
return Math.round(base * (0.75 + Math.random() * 0.5));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function withQueryParam(url, key, value) {
|
|
503
|
+
const next = new URL(url);
|
|
504
|
+
next.searchParams.set(key, value);
|
|
505
|
+
return next.toString();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function uploadTimeoutMsFor(bytes) {
|
|
509
|
+
const size = Number(bytes) || 0;
|
|
510
|
+
return Math.max(120_000, Math.min(15 * 60_000, Math.ceil(size / (1024 * 1024)) * 15_000));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function publicAssetPartSize(value) {
|
|
514
|
+
const parsed = Number(value);
|
|
515
|
+
if (Number.isFinite(parsed) && parsed >= 5 * 1024 * 1024) return Math.round(parsed);
|
|
516
|
+
return 16 * 1024 * 1024;
|
|
517
|
+
}
|
|
518
|
+
|
|
421
519
|
function assetOffloadFailure(error, assetSummary) {
|
|
422
520
|
const message = friendlyAssetOffloadError(error);
|
|
423
521
|
const wrapped = new Error(message);
|
|
@@ -511,6 +609,13 @@ function assetOffloadThresholdBytes(flags) {
|
|
|
511
609
|
return parseByteSize(flags['asset-threshold'] ?? process.env.GLASH_ASSET_OFFLOAD_THRESHOLD ?? '100mb');
|
|
512
610
|
}
|
|
513
611
|
|
|
612
|
+
function publicAssetBatchSize(flags) {
|
|
613
|
+
const raw = flags['asset-batch-size'] ?? process.env.GLASH_ASSET_BATCH_SIZE ?? '50';
|
|
614
|
+
const parsed = Number(raw);
|
|
615
|
+
if (!Number.isFinite(parsed) || parsed < 1) return 50;
|
|
616
|
+
return Math.max(1, Math.min(250, Math.floor(parsed)));
|
|
617
|
+
}
|
|
618
|
+
|
|
514
619
|
function parseByteSize(value) {
|
|
515
620
|
if (typeof value === 'number') return value;
|
|
516
621
|
const raw = String(value || '').trim().toLowerCase();
|
package/package.json
CHANGED