mnfst-publish 0.1.8 → 0.1.9

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