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.
- package/manifest.publish.mjs +203 -14
- package/package.json +1 -1
package/manifest.publish.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
523
|
-
} catch {
|
|
524
|
-
|
|
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('');
|