@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 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
 
@@ -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, 100)) {
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
- const blob = await openAsBlob(join(process.cwd(), 'public', asset.path), { type: local.contentType });
389
- const res = await fetch(asset.uploadUrl, {
390
- method: asset.method || 'PUT',
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
- new Error(`Upload ${asset.path} failed with HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glash/cli",
3
- "version": "0.2.31",
3
+ "version": "0.2.32",
4
4
  "description": "glashDB command-line interface — deploy projects, pull source + env, manage env vars.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://glashdb.com",