mnfst-publish 0.1.8 → 0.1.10

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 (2) hide show
  1. package/manifest.publish.mjs +203 -14
  2. package/package.json +1 -1
@@ -301,7 +301,205 @@ export function collectFiles(root, outputDir) {
301
301
  // .manifestignore applies ON TOP of gitignore, so a versioned file can still be
302
302
  // kept out of the published bundle.
303
303
  const publishIgnored = loadPublishIgnore(root);
304
- return rels.filter((r) => !isExcludedPath(r) && !publishIgnored(r));
304
+ return rels
305
+ .filter((r) => !isExcludedPath(r) && !publishIgnored(r))
306
+ // `git ls-files -c` reports paths from the index, not the working tree — a file
307
+ // committed once (e.g. an old build's <locale>/docs/** output) then deleted on
308
+ // disk without `git rm` still shows up here. Ship what's actually there; a
309
+ // stale tracked-but-missing path would otherwise crash buildZip with ENOENT
310
+ // (seen with mnfst-publish --no-render after a re-render dropped locale
311
+ // variants under an excluded route prefix).
312
+ .filter((r) => existsSync(join(root, r)));
313
+ }
314
+
315
+ // --- Upload + publish status ------------------------------------------------
316
+
317
+ // We ask the upload endpoint (via ASYNC_HEADER) to acknowledge with 202
318
+ // `status:"ingesting"` as soon as the bundle lands and finish the writes in the
319
+ // background, rather than holding the connection — that hold is what used to
320
+ // time out on a distant link. The outcome then arrives on <upload-url>/status.
321
+ // A server that predates this ignores the header and answers synchronously with
322
+ // {ok:true}; both are handled below.
323
+ const POLL_FIRST_MS = 1000;
324
+ const POLL_MAX_MS = 5000;
325
+ const POLL_TOTAL_MS = 10 * 60 * 1000;
326
+ // A status read that stays "pending" this long means the bundle never actually
327
+ // arrived. The grace matters because the server's token record is eventually
328
+ // consistent, so a brief stale "pending" right after an upload is normal.
329
+ const PENDING_GRACE_MS = 15_000;
330
+ const PROGRESS_EVERY_MS = 15_000;
331
+
332
+ // Tells the server we know how to poll, so it can hand back a 202 instead of
333
+ // holding the connection. A server that doesn't know the header just ignores it
334
+ // and answers synchronously — which is exactly the fallback handled below, so
335
+ // CLI and server can ship in either order.
336
+ const ASYNC_HEADER = 'x-mnfst-async';
337
+
338
+ // Same origin, same one-time token. Derived from the upload URL we already
339
+ // origin-checked — never from a URL the server hands back.
340
+ export function statusUrlFor(uploadUrl) {
341
+ return uploadUrl.replace(/\/+$/, '') + '/status';
342
+ }
343
+
344
+ /**
345
+ * Poll <upload-url>/status until the publish settles. Resolves `{state, body}`:
346
+ * done | error the publish finished (body is the server's payload)
347
+ * unknown server says the token is gone (expired / never existed)
348
+ * unsupported no status route at all — server predates it
349
+ * pending stayed "pending" past the grace: the bundle never landed
350
+ * timeout still running when the overall cap ran out
351
+ */
352
+ export async function pollPublishStatus(statusUrl, opts = {}) {
353
+ const {
354
+ fetchImpl = fetch,
355
+ sleepImpl = sleep,
356
+ log: logFn = log,
357
+ now = Date.now,
358
+ totalMs = POLL_TOTAL_MS,
359
+ pendingGraceMs = Infinity,
360
+ } = opts;
361
+ const started = now();
362
+ let delay = POLL_FIRST_MS;
363
+ let lastProgress = started;
364
+
365
+ for (;;) {
366
+ let status = 0;
367
+ let json = null;
368
+ try {
369
+ const res = await fetchImpl(statusUrl, { method: 'GET', headers: { accept: 'application/json' } });
370
+ status = res.status;
371
+ const text = await res.text();
372
+ try {
373
+ json = text ? JSON.parse(text) : null;
374
+ } catch {
375
+ /* not JSON — treated as an untagged response below */
376
+ }
377
+ } catch {
378
+ /* blip while polling — fall through and try again */
379
+ }
380
+
381
+ if (status === 404) {
382
+ // Tagged → this server knows the route and says the token is gone.
383
+ // Untagged (Hono's plain-text 404) → the server has no status route.
384
+ return json && json.status === 'unknown' ? { state: 'unknown', body: json } : { state: 'unsupported' };
385
+ }
386
+ if (json && json.status === 'done') return { state: 'done', body: json };
387
+ if (json && json.status === 'error') return { state: 'error', body: json };
388
+ if (json && json.status === 'pending' && now() - started >= pendingGraceMs) {
389
+ return { state: 'pending', body: json };
390
+ }
391
+ // An HTTP error body that skips the {status:'error'} tag entirely (a plain
392
+ // {error, message} shape, same as an upload-time rejection) is still a
393
+ // settled failure, not a blip — UNLESS it's 429 (rate limited) or a 5xx,
394
+ // both of which are expected to be transient and worth retrying.
395
+ if (json && json.error && !json.status && status >= 400 && status !== 429 && status < 500) {
396
+ return { state: 'error', body: json };
397
+ }
398
+ // Anything else (ingesting, pending inside the grace, 429, 5xx, a blip) —
399
+ // keep waiting.
400
+
401
+ const elapsed = now() - started;
402
+ if (elapsed >= totalMs) return { state: 'timeout' };
403
+ if (elapsed - (lastProgress - started) >= PROGRESS_EVERY_MS) {
404
+ lastProgress = now();
405
+ logFn(` still publishing… (${Math.round(elapsed / 1000)}s)`);
406
+ }
407
+ await sleepImpl(delay);
408
+ delay = Math.min(delay * 2, POLL_MAX_MS);
409
+ }
410
+ }
411
+
412
+ /** Turn a settled poll result into the success payload, or throw a plain-language error. */
413
+ function settledPayload(res) {
414
+ if (res.state === 'done') return res.body;
415
+ if (res.state === 'error') {
416
+ const b = res.body || {};
417
+ const retrySafe = b.error === 'retry_upload' ? ' Re-running the same command is safe and cheap — nothing was double-charged or double-shipped.' : '';
418
+ throw new Error(`publish failed${b.error ? ` (${b.error})` : ''}: ${b.message || 'the server rejected the upload.'}${retrySafe}`);
419
+ }
420
+ if (res.state === 'unknown') {
421
+ throw new Error('this publish link expired before the upload finished. Run the publish again.');
422
+ }
423
+ if (res.state === 'timeout') {
424
+ throw new Error(
425
+ 'the publish is still running after 10 minutes. It may yet finish — check the deployment in Claude ' +
426
+ 'with the Manifest connector before publishing again.',
427
+ );
428
+ }
429
+ throw new Error("couldn't determine whether the publish finished. Check the deployment before publishing again.");
430
+ }
431
+
432
+ /**
433
+ * POST the bundle and return the final success payload, whichever protocol the
434
+ * server speaks — synchronous {ok:true} (older servers) or 202 + status polling.
435
+ * Throws on a real failure.
436
+ */
437
+ export async function uploadBundle(uploadUrl, zip, opts = {}) {
438
+ const { fetchImpl = fetch, sleepImpl = sleep, log: logFn = log, tries = 4, ...pollOpts } = opts;
439
+ const statusUrl = statusUrlFor(uploadUrl);
440
+ const poll = (extra) =>
441
+ pollPublishStatus(statusUrl, { fetchImpl, sleepImpl, log: logFn, ...pollOpts, ...extra });
442
+
443
+ for (let attempt = 1; attempt <= tries; attempt++) {
444
+ let res = null;
445
+ try {
446
+ res = await fetchImpl(uploadUrl, {
447
+ method: 'POST',
448
+ headers: { 'content-type': 'application/zip', [ASYNC_HEADER]: '1' },
449
+ body: zip,
450
+ });
451
+ } catch {
452
+ // The connection died — but the bundle may well have landed and be
453
+ // ingesting right now. Re-uploading blind re-sends the whole archive (and
454
+ // would only 409 anyway), so ask the status endpoint what happened.
455
+ logFn(' lost the connection during upload — checking whether it landed…');
456
+ const s = await poll({ pendingGraceMs: PENDING_GRACE_MS });
457
+ // It never arrived, or this server has no status route — either way,
458
+ // fall back to retrying the upload exactly as before.
459
+ if (s.state === 'pending' || s.state === 'unsupported') {
460
+ if (attempt < tries) {
461
+ logFn(` couldn't reach the upload server — retrying (${attempt}/${tries - 1})…`);
462
+ await sleepImpl(500 * attempt);
463
+ continue;
464
+ }
465
+ throw new Error('could not reach the upload server.');
466
+ }
467
+ return settledPayload(s);
468
+ }
469
+
470
+ const text = await res.text();
471
+ let json = null;
472
+ try {
473
+ json = text ? JSON.parse(text) : null;
474
+ } catch {
475
+ /* leave null */
476
+ }
477
+
478
+ // Synchronous server: the upload response IS the success payload.
479
+ if (res.ok && json && json.ok === true) return json;
480
+
481
+ // Async server: acknowledged, ingest running in the background.
482
+ if (res.status === 202 && json && json.status === 'ingesting') {
483
+ logFn(' upload received — finishing the publish on the server…');
484
+ return settledPayload(await poll({}));
485
+ }
486
+
487
+ // A retry met an ingest already in flight (ours, from an attempt whose
488
+ // connection died). Wait it out rather than re-uploading.
489
+ if (res.status === 409 && json && json.error === 'ingest_in_progress') {
490
+ logFn(' an earlier attempt is still publishing — waiting for it to finish…');
491
+ return settledPayload(await poll({}));
492
+ }
493
+
494
+ if (res.status >= 500 && attempt < tries) {
495
+ logFn(` the upload server had a hiccup (HTTP ${res.status}) — retrying (${attempt}/${tries - 1})…`);
496
+ await sleepImpl(500 * attempt);
497
+ continue;
498
+ }
499
+
500
+ throw new Error(`upload failed (HTTP ${res.status}): ${text.slice(0, 300)}`);
501
+ }
502
+ throw new Error('could not reach the upload server.');
305
503
  }
306
504
 
307
505
  // --- Component version stamp ------------------------------------------------
@@ -511,20 +709,11 @@ export async function main() {
511
709
  const zip = buildZip(root, rels, stampManifests(root, rels));
512
710
  log(`Uploading ${rels.length} files (${(zip.length / 1048576).toFixed(1)} MB)…`);
513
711
 
514
- const up = await fetchRetry(
515
- uploadUrl,
516
- { method: 'POST', headers: { 'content-type': 'application/zip' }, body: zip },
517
- { label: 'the upload server' },
518
- );
519
- const upText = await up.text();
520
- let upJson = null;
712
+ let upJson;
521
713
  try {
522
- upJson = JSON.parse(upText);
523
- } catch {
524
- /* ignore */
525
- }
526
- if (!up.ok || !upJson || upJson.ok !== true) {
527
- fail(`upload failed (HTTP ${up.status}): ${upText.slice(0, 300)}`);
714
+ upJson = await uploadBundle(uploadUrl, zip);
715
+ } catch (e) {
716
+ fail(e.message);
528
717
  }
529
718
 
530
719
  log('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-publish",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "One-command managed publishing for Manifest projects — render, zip, upload, live URL.",
5
5
  "type": "module",
6
6
  "bin": {