uniweb 0.36.0 → 0.37.1
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/package.json +7 -7
- package/src/commands/publish.js +59 -25
- package/src/commands/update.js +38 -1
- package/src/framework-index.json +4 -4
- package/src/templates/fetchers/github.js +7 -18
- package/src/templates/fetchers/npm.js +7 -18
- package/src/templates/fetchers/release.js +7 -19
- package/src/utils/asset-upload.js +12 -2
- package/src/utils/code-upload.js +12 -2
- package/src/utils/dep-survey.js +53 -1
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/site-data-upload.js +113 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.1",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,15 +41,15 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/
|
|
45
|
-
"@uniweb/
|
|
44
|
+
"@uniweb/core": "^0.16.0",
|
|
45
|
+
"@uniweb/kit": "^0.15.3",
|
|
46
46
|
"@uniweb/runtime": "^0.13.5",
|
|
47
|
-
"@uniweb/
|
|
47
|
+
"@uniweb/semantic-parser": "^1.4.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/build": "^0.
|
|
51
|
-
"@uniweb/
|
|
52
|
-
"@uniweb/
|
|
50
|
+
"@uniweb/build": "^0.33.0",
|
|
51
|
+
"@uniweb/semantic-parser": "^1.4.0",
|
|
52
|
+
"@uniweb/content-reader": "^1.2.4"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
package/src/commands/publish.js
CHANGED
|
@@ -95,7 +95,10 @@ import {
|
|
|
95
95
|
reportPaymentRefusal
|
|
96
96
|
} from '../backend/payment-handoff.js'
|
|
97
97
|
import { reportSchemalessQueries } from '../utils/schemaless-report.js'
|
|
98
|
-
import {
|
|
98
|
+
import {
|
|
99
|
+
uploadSiteData,
|
|
100
|
+
describeDataRefusal
|
|
101
|
+
} from '../utils/site-data-upload.js'
|
|
99
102
|
|
|
100
103
|
const c = {
|
|
101
104
|
reset: '\x1b[0m',
|
|
@@ -456,7 +459,12 @@ export async function publish(args = []) {
|
|
|
456
459
|
// destructive — a placeholder file, created meaning to fill it in — so the count
|
|
457
460
|
// is reported and confirmed before anything is sent.
|
|
458
461
|
{
|
|
459
|
-
const guard = await guardEmptyRecords({
|
|
462
|
+
const guard = await guardEmptyRecords({
|
|
463
|
+
siteDir,
|
|
464
|
+
args,
|
|
465
|
+
warn: say.warn,
|
|
466
|
+
note: say.dim
|
|
467
|
+
})
|
|
460
468
|
if (!guard.ok) return { exitCode: 1 }
|
|
461
469
|
}
|
|
462
470
|
|
|
@@ -602,28 +610,56 @@ export async function publish(args = []) {
|
|
|
602
610
|
// does not exist, which is the failure this work kept catching in others.
|
|
603
611
|
// `client.discover()` is the mechanism if that changes — `DISCOVERY_DEFAULTS`
|
|
604
612
|
// makes an absent key non-breaking by construction.
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
613
|
+
//
|
|
614
|
+
// ⛔ UNCONDITIONAL — `if (ball)` was here until 2026-09-01 and it was the bug.
|
|
615
|
+
//
|
|
616
|
+
// `collectSchemalessData` returns null for an empty set, so a publish that
|
|
617
|
+
// carried no schema-less data sent NO PLAN AT ALL. The backend reconciles a
|
|
618
|
+
// site's data usage against this manifest, and a request that never arrives
|
|
619
|
+
// is not a manifest saying "none" — it is silence, indistinguishable from a
|
|
620
|
+
// publish that never happened. So deleting your LAST schema-less collection
|
|
621
|
+
// — the exact operation the reconcile exists to make free — was the one
|
|
622
|
+
// operation that could not be expressed, and the site kept paying for it
|
|
623
|
+
// until the whole site was deleted.
|
|
624
|
+
//
|
|
625
|
+
// ⭐ The general shape, worth more than the fix: an EMPTY set and NO set are
|
|
626
|
+
// different statements, and an `if (x)` guard collapses them into one. The
|
|
627
|
+
// cost is always paid by whoever is downstream trying to tell them apart.
|
|
628
|
+
//
|
|
629
|
+
// Both halves agreed in channel backend-framework-82f2 (2026-09-01); the
|
|
630
|
+
// backend's route accepts an empty `files` array as of the same exchange.
|
|
631
|
+
say.info('Uploading schema-less record data…')
|
|
632
|
+
try {
|
|
633
|
+
const r = await uploadSiteData({
|
|
634
|
+
apiBase: client.origin,
|
|
635
|
+
token: await client.token(),
|
|
636
|
+
siteUuid: site.uuid,
|
|
637
|
+
ball,
|
|
638
|
+
onProgress: (m) => say.dim(` ${m}`)
|
|
639
|
+
})
|
|
640
|
+
if (r.failed.length) {
|
|
641
|
+
// A file whose bytes did not land must not be published: the site would
|
|
642
|
+
// serve a stale copy or 404, and the only trace would be a warning.
|
|
643
|
+
say.err(
|
|
644
|
+
`${r.failed.length} data file(s) failed to upload — not publishing.`
|
|
645
|
+
)
|
|
646
|
+
for (const f of r.failed) say.dim(` ${f.path} (HTTP ${f.status})`)
|
|
625
647
|
return { exitCode: 1 }
|
|
626
648
|
}
|
|
649
|
+
say.dim(`Record data : ${r.uploaded.length} file(s) [${r.mode}]`)
|
|
650
|
+
} catch (err) {
|
|
651
|
+
// A typed plan refusal gets its own account (quota, or whatever else the
|
|
652
|
+
// backend names); anything else falls through to the raw message. Same
|
|
653
|
+
// treatment the asset plan and the site create already get — this was the
|
|
654
|
+
// last door still printing the problem document at the user verbatim.
|
|
655
|
+
const refusal = describeDataRefusal(err)
|
|
656
|
+
if (refusal) {
|
|
657
|
+
say.err(refusal.headline)
|
|
658
|
+
for (const line of refusal.notes) say.dim(line)
|
|
659
|
+
} else {
|
|
660
|
+
say.err(`Record data upload failed: ${err.message}`)
|
|
661
|
+
}
|
|
662
|
+
return { exitCode: 1 }
|
|
627
663
|
}
|
|
628
664
|
|
|
629
665
|
// 5. Push the site (content + folder) over the send-only-changed cache —
|
|
@@ -680,9 +716,7 @@ export async function publish(args = []) {
|
|
|
680
716
|
? { baseVersions, itemBaseVersions: readItemBaseVersions(siteDir) }
|
|
681
717
|
: {}),
|
|
682
718
|
...(Object.keys(injectInfo).length ? { injectInfo } : {}),
|
|
683
|
-
...(Object.keys(ext.pins).length
|
|
684
|
-
? { injectExtensions: ext.pins }
|
|
685
|
-
: {}),
|
|
719
|
+
...(Object.keys(ext.pins).length ? { injectExtensions: ext.pins } : {}),
|
|
686
720
|
...(assetRewrite ? { assetRewrite } : {}),
|
|
687
721
|
...(assetIds ? { assetIds } : {})
|
|
688
722
|
})
|
package/src/commands/update.js
CHANGED
|
@@ -257,7 +257,15 @@ export function printSurvey(
|
|
|
257
257
|
statusText = `${colors.dim}aligned${colors.reset}`
|
|
258
258
|
} else if (row.status === 'behind') {
|
|
259
259
|
icon = `${colors.yellow}✗${colors.reset}`
|
|
260
|
-
|
|
260
|
+
// ⛔ `behind` alone was the whole message, in one colour, for every
|
|
261
|
+
// distance. In 0.x the minor slot is where our breaking changes live
|
|
262
|
+
// (`publish.js` derives it from a breaking marker and nothing else), so
|
|
263
|
+
// `^0.14.1 → ^0.16.0` is two releases a consumer must act on and
|
|
264
|
+
// `^0.15.0 → ^0.15.2` is not — and the table said the same thing about
|
|
265
|
+
// both. Naming the class is the difference between a list and a signal.
|
|
266
|
+
statusText = row.breaking
|
|
267
|
+
? `${colors.red}${row.bump} · BREAKING${colors.reset}`
|
|
268
|
+
: `${colors.yellow}${row.bump}${colors.reset}`
|
|
261
269
|
} else {
|
|
262
270
|
icon = `${colors.cyan}↑${colors.reset}`
|
|
263
271
|
statusText = `${colors.cyan}ahead of CLI${colors.reset}`
|
|
@@ -272,6 +280,35 @@ export function printSurvey(
|
|
|
272
280
|
` ${colors.dim}(${alignedCount} other${alignedCount === 1 ? '' : 's'} already aligned — ${colors.reset}${colors.cyan}--verbose${colors.reset}${colors.dim} to list)${colors.reset}`
|
|
273
281
|
)
|
|
274
282
|
}
|
|
283
|
+
|
|
284
|
+
// ⭐ **A summary, because the table scrolls and `--yes` does not stop.** The
|
|
285
|
+
// per-row label above is invisible to the case that matters most: a CI or an
|
|
286
|
+
// agent running `update --yes`, where nobody reads a table and the only
|
|
287
|
+
// artifact is a log. This block names the packages, so "what did that
|
|
288
|
+
// upgrade cross?" is answerable afterwards from the log alone.
|
|
289
|
+
//
|
|
290
|
+
// ⚖️ It reports; it does not gate. Crossing a 0.x minor IS the ordinary way
|
|
291
|
+
// to take a Uniweb update — gating it would gate nearly every real upgrade
|
|
292
|
+
// and make the verb useless. The decision stays the operator's; what changed
|
|
293
|
+
// is that they can now make it knowingly.
|
|
294
|
+
const breakingRows = report.rows.filter((r) => r.breaking)
|
|
295
|
+
if (breakingRows.length > 0) {
|
|
296
|
+
const names = [...new Set(breakingRows.map((r) => r.name))]
|
|
297
|
+
log('')
|
|
298
|
+
log(
|
|
299
|
+
`${colors.red}⚠${colors.reset} ${colors.bright}${names.length} package${names.length === 1 ? '' : 's'} cross${names.length === 1 ? 'es' : ''} a breaking boundary:${colors.reset}`
|
|
300
|
+
)
|
|
301
|
+
for (const name of names) {
|
|
302
|
+
const r = breakingRows.find((x) => x.name === name)
|
|
303
|
+
log(` ${colors.red}${name}${colors.reset} ${r.current} → ${r.target} ${colors.dim}(${r.bump})${colors.reset}`)
|
|
304
|
+
}
|
|
305
|
+
log(
|
|
306
|
+
` ${colors.dim}In 0.x the minor slot is where breaking changes go, so these are releases${colors.reset}`
|
|
307
|
+
)
|
|
308
|
+
log(
|
|
309
|
+
` ${colors.dim}you may need to act on. Read their changelogs before shipping.${colors.reset}`
|
|
310
|
+
)
|
|
311
|
+
}
|
|
275
312
|
log('')
|
|
276
313
|
}
|
|
277
314
|
|
package/src/framework-index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"generatedAt": "2026-09-02T17:42:38.924Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
6
|
"version": "0.2.2",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
]
|
|
11
11
|
},
|
|
12
12
|
"@uniweb/build": {
|
|
13
|
-
"version": "0.
|
|
13
|
+
"version": "0.33.0",
|
|
14
14
|
"path": "framework/build",
|
|
15
15
|
"deps": [
|
|
16
16
|
"@uniweb/content-reader",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
]
|
|
55
55
|
},
|
|
56
56
|
"@uniweb/kit": {
|
|
57
|
-
"version": "0.15.
|
|
57
|
+
"version": "0.15.3",
|
|
58
58
|
"path": "framework/kit",
|
|
59
59
|
"deps": [
|
|
60
60
|
"@uniweb/core",
|
|
@@ -119,7 +119,7 @@
|
|
|
119
119
|
"deps": []
|
|
120
120
|
},
|
|
121
121
|
"@uniweb/unipress": {
|
|
122
|
-
"version": "0.8.
|
|
122
|
+
"version": "0.8.20",
|
|
123
123
|
"path": "framework/unipress",
|
|
124
124
|
"deps": [
|
|
125
125
|
"@uniweb/build",
|
|
@@ -8,6 +8,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
|
|
8
8
|
import { tmpdir } from 'node:os'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
import * as tar from 'tar'
|
|
11
|
+
import { fetchWithRetry as sharedFetchWithRetry } from '../../utils/fetch-retry.js'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Fetch a template from a GitHub repository
|
|
@@ -78,21 +79,9 @@ export async function fetchGitHubTemplate(owner, repo, options = {}) {
|
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const response = await fetch(url, {
|
|
88
|
-
...options,
|
|
89
|
-
signal: AbortSignal.timeout(60000) // 60s timeout for GitHub (can be slow)
|
|
90
|
-
})
|
|
91
|
-
return response
|
|
92
|
-
} catch (err) {
|
|
93
|
-
if (attempt === maxRetries) throw err
|
|
94
|
-
const delay = Math.min(1000 * Math.pow(2, attempt), 10000)
|
|
95
|
-
await new Promise((r) => setTimeout(r, delay))
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
82
|
+
// Retry + timeout live in one place now (`../../utils/fetch-retry.js`).
|
|
83
|
+
// This file carried its own copy, as did the other two fetchers — three
|
|
84
|
+
// byte-identical implementations, and none reachable from the upload paths
|
|
85
|
+
// that had no retry at all. The wrapper keeps this caller's own timeout.
|
|
86
|
+
const fetchWithRetry = (url, options = {}, maxRetries = 3) =>
|
|
87
|
+
sharedFetchWithRetry(url, { ...options }, { retries: maxRetries, timeoutMs: 60000 })
|
|
@@ -8,6 +8,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
|
|
8
8
|
import { tmpdir } from 'node:os'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
import * as tar from 'tar'
|
|
11
|
+
import { fetchWithRetry as sharedFetchWithRetry } from '../../utils/fetch-retry.js'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Fetch a template from npm registry
|
|
@@ -76,21 +77,9 @@ export async function fetchNpmTemplate(packageName, options = {}) {
|
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const response = await fetch(url, {
|
|
86
|
-
...options,
|
|
87
|
-
signal: AbortSignal.timeout(30000) // 30s timeout
|
|
88
|
-
})
|
|
89
|
-
return response
|
|
90
|
-
} catch (err) {
|
|
91
|
-
if (attempt === maxRetries) throw err
|
|
92
|
-
const delay = Math.min(1000 * Math.pow(2, attempt), 10000)
|
|
93
|
-
await new Promise((r) => setTimeout(r, delay))
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
80
|
+
// Retry + timeout live in one place now (`../../utils/fetch-retry.js`).
|
|
81
|
+
// This file carried its own copy, as did the other two fetchers — three
|
|
82
|
+
// byte-identical implementations, and none reachable from the upload paths
|
|
83
|
+
// that had no retry at all. The wrapper keeps this caller's own timeout.
|
|
84
|
+
const fetchWithRetry = (url, options = {}, maxRetries = 3) =>
|
|
85
|
+
sharedFetchWithRetry(url, { ...options }, { retries: maxRetries, timeoutMs: 30000 })
|
|
@@ -8,6 +8,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
|
|
8
8
|
import { tmpdir } from 'node:os'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
import * as tar from 'tar'
|
|
11
|
+
import { fetchWithRetry as sharedFetchWithRetry } from '../../utils/fetch-retry.js'
|
|
11
12
|
|
|
12
13
|
// GitHub repository for official templates
|
|
13
14
|
const TEMPLATES_REPO = 'uniweb/templates'
|
|
@@ -222,22 +223,9 @@ async function handleGitHubError(response) {
|
|
|
222
223
|
throw new Error(`GitHub API error: ${response.status}`)
|
|
223
224
|
}
|
|
224
225
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const response = await fetch(url, {
|
|
232
|
-
...options,
|
|
233
|
-
redirect: 'follow',
|
|
234
|
-
signal: AbortSignal.timeout(60000) // 60s timeout
|
|
235
|
-
})
|
|
236
|
-
return response
|
|
237
|
-
} catch (err) {
|
|
238
|
-
if (attempt === maxRetries) throw err
|
|
239
|
-
const delay = Math.min(1000 * Math.pow(2, attempt), 10000)
|
|
240
|
-
await new Promise((r) => setTimeout(r, delay))
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
226
|
+
// Retry + timeout live in one place now (`../../utils/fetch-retry.js`).
|
|
227
|
+
// This file carried its own copy, as did the other two fetchers — three
|
|
228
|
+
// byte-identical implementations, and none reachable from the upload paths
|
|
229
|
+
// that had no retry at all. The wrapper keeps this caller's own timeout.
|
|
230
|
+
const fetchWithRetry = (url, options = {}, maxRetries = 3) =>
|
|
231
|
+
sharedFetchWithRetry(url, { redirect: 'follow', ...options }, { retries: maxRetries, timeoutMs: 60000 })
|
|
@@ -30,6 +30,7 @@ import { createHash } from 'node:crypto'
|
|
|
30
30
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
31
31
|
import { join } from 'node:path'
|
|
32
32
|
import { contentTypeFor } from './code-upload.js'
|
|
33
|
+
import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
36
|
* Walk a built site's `dist/assets/` and produce the upload file list. `path` is
|
|
@@ -202,11 +203,20 @@ export async function uploadSiteAssets({
|
|
|
202
203
|
try {
|
|
203
204
|
// The plan's url may be origin-relative (direct mode → the backend) or
|
|
204
205
|
// absolute (presigned → storage); new URL() resolves both.
|
|
205
|
-
|
|
206
|
+
// ⭐ **Retried.** A single connection-level failure used to fail the whole
|
|
207
|
+
// verb after every other file had already gone up. The PUT is idempotent
|
|
208
|
+
// here — same bytes, same target, verified by `x-uniweb-sha256` on receipt
|
|
209
|
+
// — so repeating it is safe by construction, which is why this opts in to
|
|
210
|
+
// `retryOnStatus` rather than inheriting it.
|
|
211
|
+
//
|
|
212
|
+
// ⚠️ 120s per attempt, not the helper's 30s default: this bounds the time
|
|
213
|
+
// to send a whole BODY, and aborting a legitimately slow upload only to
|
|
214
|
+
// retry it is worse than not timing out at all.
|
|
215
|
+
putRes = await fetchWithRetry(new URL(up.url, origin), {
|
|
206
216
|
method: up.method || 'PUT',
|
|
207
217
|
headers,
|
|
208
218
|
body: src.bytes ?? readFileSync(src.diskPath)
|
|
209
|
-
})
|
|
219
|
+
}, { timeoutMs: 120_000, retryOnStatus: isTransientStatus })
|
|
210
220
|
} catch (err) {
|
|
211
221
|
failed.push({ path: src.path, status: 0, detail: err.message })
|
|
212
222
|
continue
|
package/src/utils/code-upload.js
CHANGED
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
import { createHash } from 'node:crypto'
|
|
42
42
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
43
43
|
import { join } from 'node:path'
|
|
44
|
+
import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
|
|
44
45
|
|
|
45
46
|
// Extension → declared content type. Extension-honest by construction (Vite
|
|
46
47
|
// output); anything unknown ships as octet-stream.
|
|
@@ -287,7 +288,16 @@ export async function uploadFoundationCode({
|
|
|
287
288
|
}
|
|
288
289
|
const bytes = readFileSync(join(distDir, file.path))
|
|
289
290
|
try {
|
|
290
|
-
|
|
291
|
+
// ⭐ **Retried.** A single connection-level failure used to fail the whole
|
|
292
|
+
// verb after every other file had already gone up. The PUT is idempotent
|
|
293
|
+
// here — same bytes, same target, verified by `x-uniweb-sha256` on receipt
|
|
294
|
+
// — so repeating it is safe by construction, which is why this opts in to
|
|
295
|
+
// `retryOnStatus` rather than inheriting it.
|
|
296
|
+
//
|
|
297
|
+
// ⚠️ 120s per attempt, not the helper's 30s default: this bounds the time
|
|
298
|
+
// to send a whole BODY, and aborting a legitimately slow upload only to
|
|
299
|
+
// retry it is worse than not timing out at all.
|
|
300
|
+
const res = await fetchWithRetry(new URL(target.url, origin), {
|
|
291
301
|
method: target.method || 'PUT',
|
|
292
302
|
// x-uniweb-sha256: optional integrity guard — direct mode verifies
|
|
293
303
|
// the received bytes and 400s on mismatch (corruption-in-flight).
|
|
@@ -297,7 +307,7 @@ export async function uploadFoundationCode({
|
|
|
297
307
|
'x-uniweb-sha256': file.sha256
|
|
298
308
|
},
|
|
299
309
|
body: bytes
|
|
300
|
-
})
|
|
310
|
+
}, { timeoutMs: 120_000, retryOnStatus: isTransientStatus })
|
|
301
311
|
if (res.ok) {
|
|
302
312
|
uploaded.push(file.path)
|
|
303
313
|
onProgress(`${file.path} (${file.size} bytes)`)
|
package/src/utils/dep-survey.js
CHANGED
|
@@ -33,6 +33,53 @@ export function stripVersionRange(spec) {
|
|
|
33
33
|
)
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* How far a bump moves, in the terms our versioning actually uses.
|
|
38
|
+
*
|
|
39
|
+
* ⭐ **In 0.x the MINOR slot is the breaking one, and it is the only channel we
|
|
40
|
+
* have.** `scripts/framework/publish.js` derives a package's bump from its own
|
|
41
|
+
* commits: a breaking marker means minor, everything else patch. So
|
|
42
|
+
* `^0.14.1 → ^0.16.0` is two breaking releases and `^0.15.0 → ^0.15.2` is not —
|
|
43
|
+
* and until 2026-09-02 `update` printed both as `behind`, in the same colour,
|
|
44
|
+
* and `--yes` applied them without a word.
|
|
45
|
+
*
|
|
46
|
+
* That is the one signal the version scheme exists to send, discarded by the
|
|
47
|
+
* command we tell every project to run — `AGENTS.md` ships that instruction
|
|
48
|
+
* into every scaffold. The `flows` lane crossed `@uniweb/core` `^0.14.1 →
|
|
49
|
+
* ^0.15.0` this way and learned it afterwards, from a changelog.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} from - the currently declared range or version
|
|
52
|
+
* @param {string} to - the version the matrix carries
|
|
53
|
+
* @returns {'patch'|'minor'|'major'|'none'}
|
|
54
|
+
*/
|
|
55
|
+
export function bumpClass(from, to) {
|
|
56
|
+
const [aMaj = 0, aMin = 0, aPat = 0] = stripVersionRange(from).split('.').map(Number)
|
|
57
|
+
const [bMaj = 0, bMin = 0, bPat = 0] = stripVersionRange(to).split('.').map(Number)
|
|
58
|
+
if (bMaj !== aMaj) return 'major'
|
|
59
|
+
if (bMin !== aMin) return 'minor'
|
|
60
|
+
if (bPat !== aPat) return 'patch'
|
|
61
|
+
return 'none'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Is this crossing one a consumer must act on?
|
|
66
|
+
*
|
|
67
|
+
* A major always is. A minor is **when the major is 0**, because that is where
|
|
68
|
+
* our scheme puts breaking changes — and npm agrees, which is the check that
|
|
69
|
+
* makes this more than our own convention: `^0.14.1` admits `0.14.x` and
|
|
70
|
+
* refuses `0.15.0`, so the range itself already treats the slot as a wall.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} from
|
|
73
|
+
* @param {string} to
|
|
74
|
+
* @returns {boolean}
|
|
75
|
+
*/
|
|
76
|
+
export function isBreakingBump(from, to) {
|
|
77
|
+
const cls = bumpClass(from, to)
|
|
78
|
+
if (cls === 'major') return true
|
|
79
|
+
if (cls !== 'minor') return false
|
|
80
|
+
return Number(stripVersionRange(to).split('.')[0]) === 0
|
|
81
|
+
}
|
|
82
|
+
|
|
36
83
|
/**
|
|
37
84
|
* Compare two version specs (range prefix tolerated). Returns 1 / -1 / 0.
|
|
38
85
|
* @param {string} a
|
|
@@ -115,7 +162,12 @@ export async function surveyWorkspaceDeps(workspaceDir) {
|
|
|
115
162
|
name,
|
|
116
163
|
current,
|
|
117
164
|
target,
|
|
118
|
-
status
|
|
165
|
+
status,
|
|
166
|
+
// Classified here rather than at print time so every consumer of a
|
|
167
|
+
// survey row gets the same answer — the report, the summary, and any
|
|
168
|
+
// gate a caller applies.
|
|
169
|
+
bump: status === 'behind' ? bumpClass(current, target) : 'none',
|
|
170
|
+
breaking: status === 'behind' && isBreakingBump(current, target)
|
|
119
171
|
})
|
|
120
172
|
}
|
|
121
173
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fetch` with bounded retry and a timeout. **The one implementation.**
|
|
3
|
+
*
|
|
4
|
+
* ## Why this file exists
|
|
5
|
+
*
|
|
6
|
+
* There were three, byte-identical apart from a timeout constant — one each in
|
|
7
|
+
* `templates/fetchers/{npm,release,github}.js` — and **none of them was reachable
|
|
8
|
+
* from the code that needed it most.** The three upload paths (`code-upload`,
|
|
9
|
+
* `asset-upload`, `site-data-upload`) had no retry at all, so a single
|
|
10
|
+
* connection-level failure on any one file failed the whole `register` / `push`
|
|
11
|
+
* / `publish` after every other file had already gone up.
|
|
12
|
+
*
|
|
13
|
+
* ⭐ **Measured by the `flows` lane, 2026-09-02:** roughly one red per full suite
|
|
14
|
+
* run, landing on a *different flow each time* and passing on re-run every time —
|
|
15
|
+
* the signature of an unretried transient, with the network as the variable
|
|
16
|
+
* rather than any flow. Their symptom was recorded as "not diagnosed, may be the
|
|
17
|
+
* manor, the CDN, or the connection"; it was none of those. It was us.
|
|
18
|
+
*
|
|
19
|
+
* ## ⚠️ What a retry does NOT do
|
|
20
|
+
*
|
|
21
|
+
* It masks whatever drops the connection. That is the right trade for a genuine
|
|
22
|
+
* transient and the wrong one for a systematic fault: if the failure rate ever
|
|
23
|
+
* climbs, a retry turns a fast red into a slow one and hides the cause. **Do not
|
|
24
|
+
* read a green run as evidence the network is healthy.**
|
|
25
|
+
*
|
|
26
|
+
* ## Retrying a PUT is safe HERE, and not in general
|
|
27
|
+
*
|
|
28
|
+
* These uploads are idempotent by construction — the same bytes to the same
|
|
29
|
+
* target, with an `x-uniweb-sha256` integrity guard the far side verifies. That
|
|
30
|
+
* is why `retryOnStatus` is **opt-in** rather than the default: a caller has to
|
|
31
|
+
* assert its own idempotence, because this helper cannot know it.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const DEFAULT_RETRIES = 3
|
|
35
|
+
const DEFAULT_TIMEOUT_MS = 30_000
|
|
36
|
+
const MAX_BACKOFF_MS = 10_000
|
|
37
|
+
|
|
38
|
+
/** Exponential backoff, capped. Attempt is 1-based. */
|
|
39
|
+
const backoffMs = (attempt) => Math.min(1000 * Math.pow(2, attempt), MAX_BACKOFF_MS)
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string|URL} url
|
|
43
|
+
* @param {RequestInit} [options] - passed to `fetch`; `signal` is supplied here.
|
|
44
|
+
* @param {Object} [config]
|
|
45
|
+
* @param {number} [config.retries=3] - total attempts, not extra ones.
|
|
46
|
+
* @param {number} [config.timeoutMs=30000] - per ATTEMPT, not for the whole call.
|
|
47
|
+
* ⚠️ For an upload this bounds the time to send a whole body, so a large file
|
|
48
|
+
* on a slow link needs a generous value — too short and a legitimately slow
|
|
49
|
+
* upload is aborted and then retried, which is worse than no timeout at all.
|
|
50
|
+
* @param {(status: number) => boolean} [config.retryOnStatus] - opt-in. Without
|
|
51
|
+
* it only a THROWN fetch is retried (a connection-level failure, which is what
|
|
52
|
+
* surfaces as `status: 0` in the upload paths). A caller passing this asserts
|
|
53
|
+
* its request is safe to repeat.
|
|
54
|
+
* @param {(info: {attempt: number, of: number, reason: string, delayMs: number}) => void} [config.onRetry]
|
|
55
|
+
* Called before each wait. ⭐ Worth passing: a retry that no one can see turns
|
|
56
|
+
* a visible failure into an invisible slowdown, which is its own defect.
|
|
57
|
+
* @returns {Promise<Response>}
|
|
58
|
+
*/
|
|
59
|
+
export async function fetchWithRetry(url, options = {}, config = {}) {
|
|
60
|
+
const {
|
|
61
|
+
retries = DEFAULT_RETRIES,
|
|
62
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
63
|
+
retryOnStatus = null,
|
|
64
|
+
onRetry = null,
|
|
65
|
+
} = config
|
|
66
|
+
|
|
67
|
+
let lastErr = null
|
|
68
|
+
|
|
69
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetch(url, {
|
|
72
|
+
...options,
|
|
73
|
+
signal: options.signal ?? AbortSignal.timeout(timeoutMs),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
if (attempt < retries && retryOnStatus && retryOnStatus(res.status)) {
|
|
77
|
+
const delayMs = backoffMs(attempt)
|
|
78
|
+
onRetry?.({ attempt, of: retries, reason: `HTTP ${res.status}`, delayMs })
|
|
79
|
+
await new Promise((r) => setTimeout(r, delayMs))
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return res
|
|
84
|
+
} catch (err) {
|
|
85
|
+
lastErr = err
|
|
86
|
+
// The last attempt throws to the caller, which keeps this a drop-in for
|
|
87
|
+
// the three template fetchers whose contract was "throws like fetch".
|
|
88
|
+
if (attempt === retries) throw err
|
|
89
|
+
const delayMs = backoffMs(attempt)
|
|
90
|
+
onRetry?.({ attempt, of: retries, reason: err.message, delayMs })
|
|
91
|
+
await new Promise((r) => setTimeout(r, delayMs))
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* c8 ignore next 2 -- unreachable: the loop returns or throws on the last attempt */
|
|
96
|
+
throw lastErr
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The statuses worth repeating an idempotent request for: a server that failed
|
|
101
|
+
* to answer this time, or asked us to slow down.
|
|
102
|
+
*
|
|
103
|
+
* ⛔ Never a 4xx other than 429 — a rejection does not improve on repetition,
|
|
104
|
+
* and retrying one turns a clear error into a delayed clear error.
|
|
105
|
+
*
|
|
106
|
+
* @param {number} status
|
|
107
|
+
* @returns {boolean}
|
|
108
|
+
*/
|
|
109
|
+
export const isTransientStatus = (status) => status === 429 || status >= 500
|
|
@@ -51,6 +51,8 @@
|
|
|
51
51
|
*/
|
|
52
52
|
|
|
53
53
|
import { createHash } from 'node:crypto'
|
|
54
|
+
import { humanBytes } from './bytes.js'
|
|
55
|
+
import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
|
|
54
56
|
|
|
55
57
|
/**
|
|
56
58
|
* Plan + upload a site's static collection data files.
|
|
@@ -72,10 +74,23 @@ export async function uploadSiteData({
|
|
|
72
74
|
ball,
|
|
73
75
|
onProgress = () => {}
|
|
74
76
|
}) {
|
|
77
|
+
// ⛔ An EMPTY set still posts a plan, and that is the whole point of this lane
|
|
78
|
+
// being a manifest rather than a stream of files.
|
|
79
|
+
//
|
|
80
|
+
// The backend reconciles a site's data usage against what this plan declares:
|
|
81
|
+
// whatever is not in the manifest is gone, so deleting a collection stops
|
|
82
|
+
// costing on the next publish. ⭐ A plan with zero files is a STATEMENT that
|
|
83
|
+
// there are none; the ABSENCE of a request says nothing at all. Returning
|
|
84
|
+
// early here — which this did until 2026-09-01 — made "the user deleted their
|
|
85
|
+
// last schema-less collection" unexpressible on the wire, so that site kept
|
|
86
|
+
// paying for bytes it no longer served until the whole site was deleted.
|
|
87
|
+
// Nothing looked wrong at either end: no error, no warning, just a request
|
|
88
|
+
// that was never sent.
|
|
89
|
+
//
|
|
90
|
+
// Agreed both sides in channel backend-framework-82f2; the backend's plan
|
|
91
|
+
// route accepted an empty `files` array in the same exchange (it was a 400
|
|
92
|
+
// before, which is what made the omission look like the only option).
|
|
75
93
|
const entries = Object.entries(ball?.data || {})
|
|
76
|
-
if (!entries.length) {
|
|
77
|
-
return { mode: 'none', uploaded: [], failed: [], serveBase: null }
|
|
78
|
-
}
|
|
79
94
|
|
|
80
95
|
// One plan for the whole set. The per-request file cap counts a plan, so
|
|
81
96
|
// splitting would evade it rather than respect it; if a set ever exceeds it,
|
|
@@ -129,9 +144,25 @@ export async function uploadSiteData({
|
|
|
129
144
|
)
|
|
130
145
|
if (!planRes.ok) {
|
|
131
146
|
const body = await planRes.text().catch(() => '')
|
|
132
|
-
throw
|
|
147
|
+
// The PARSED problem document has to survive the throw. Flattening it into
|
|
148
|
+
// the message is what left the other two doors unable to branch on `reason`
|
|
149
|
+
// and printing raw JSON at users; this lane was the last one still doing it.
|
|
150
|
+
// Callers read `err.problem`; `describeDataRefusal` turns it into lines.
|
|
151
|
+
let problem = null
|
|
152
|
+
if (body) {
|
|
153
|
+
try {
|
|
154
|
+
const parsed = JSON.parse(body)
|
|
155
|
+
if (parsed && typeof parsed === 'object') problem = parsed
|
|
156
|
+
} catch {
|
|
157
|
+
/* not a problem document — prose refusal, or an upstream error page */
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const err = new Error(
|
|
133
161
|
`site data-uploads plan rejected: HTTP ${planRes.status}${body ? ` — ${body.slice(0, 300)}` : ''}`
|
|
134
162
|
)
|
|
163
|
+
err.status = planRes.status
|
|
164
|
+
err.problem = problem
|
|
165
|
+
throw err
|
|
135
166
|
}
|
|
136
167
|
|
|
137
168
|
const plan = await planRes.json()
|
|
@@ -149,13 +180,26 @@ export async function uploadSiteData({
|
|
|
149
180
|
if (!target) {
|
|
150
181
|
// A file the plan did not answer for is unaddressable. Report it; never
|
|
151
182
|
// invent a location for it.
|
|
152
|
-
failed.push({
|
|
183
|
+
failed.push({
|
|
184
|
+
path: f.path,
|
|
185
|
+
status: 0,
|
|
186
|
+
detail: 'no upload target in plan'
|
|
187
|
+
})
|
|
153
188
|
continue
|
|
154
189
|
}
|
|
155
190
|
try {
|
|
156
191
|
// Relative on the direct arm, absolute on presigned — `new URL` resolves
|
|
157
192
|
// both against the origin.
|
|
158
|
-
|
|
193
|
+
// ⭐ **Retried.** A single connection-level failure used to fail the whole
|
|
194
|
+
// verb after every other file had already gone up. The PUT is idempotent
|
|
195
|
+
// here — same bytes, same target, verified by `x-uniweb-sha256` on receipt
|
|
196
|
+
// — so repeating it is safe by construction, which is why this opts in to
|
|
197
|
+
// `retryOnStatus` rather than inheriting it.
|
|
198
|
+
//
|
|
199
|
+
// ⚠️ 120s per attempt, not the helper's 30s default: this bounds the time
|
|
200
|
+
// to send a whole BODY, and aborting a legitimately slow upload only to
|
|
201
|
+
// retry it is worse than not timing out at all.
|
|
202
|
+
const res = await fetchWithRetry(new URL(target.url, origin), {
|
|
159
203
|
method: target.method || 'PUT',
|
|
160
204
|
headers: {
|
|
161
205
|
'Content-Type': 'application/json',
|
|
@@ -163,7 +207,7 @@ export async function uploadSiteData({
|
|
|
163
207
|
...authHeaders
|
|
164
208
|
},
|
|
165
209
|
body: f.bytes
|
|
166
|
-
})
|
|
210
|
+
}, { timeoutMs: 120_000, retryOnStatus: isTransientStatus })
|
|
167
211
|
if (res.ok) {
|
|
168
212
|
uploaded.push(f.path)
|
|
169
213
|
onProgress(`${f.path}`)
|
|
@@ -182,3 +226,65 @@ export async function uploadSiteData({
|
|
|
182
226
|
serveBase: plan.serve_base || null
|
|
183
227
|
}
|
|
184
228
|
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Turn a typed data-uploads refusal into user-facing lines, or null when there is
|
|
232
|
+
* no typed `reason` (⇒ fall through to the generic message, degrading rather than
|
|
233
|
+
* swallowing).
|
|
234
|
+
*
|
|
235
|
+
* ⭐ The THIRD door, and the last to get one. `/dev/assets` has
|
|
236
|
+
* `describeAssetRefusal`, `POST /dev/site` has `describeCreateRefusal`, and this
|
|
237
|
+
* lane threw prose with the JSON inlined until 2026-09-01 — which is the failure
|
|
238
|
+
* the other two describers exist to prevent, so leaving it was just an untreated
|
|
239
|
+
* instance of a solved problem.
|
|
240
|
+
*
|
|
241
|
+
* ⛔ Branch on `reason`, never the status: `507` alone cannot be told from any
|
|
242
|
+
* other `507` and carries none of the numbers, and `detail` is prose the backend
|
|
243
|
+
* may reword.
|
|
244
|
+
*
|
|
245
|
+
* ⚖️ **The advice DIVERGES from the asset lane's, and that divergence is the whole
|
|
246
|
+
* reason this is a separate function rather than a reused one.** On the asset lane
|
|
247
|
+
* removing an image frees nothing — freeing is entity-deletion-granular. Here the
|
|
248
|
+
* publish declares the COMPLETE set of schema-less data files every time, so
|
|
249
|
+
* dropping a collection and re-publishing is a real way to stop paying for it.
|
|
250
|
+
* Telling a data user "editing content frees nothing" would be false, and telling
|
|
251
|
+
* an asset user "just remove it" would be worse.
|
|
252
|
+
*
|
|
253
|
+
* @param {Error & { problem?: object|null }} err
|
|
254
|
+
* @returns {{ headline: string, notes: string[] } | null}
|
|
255
|
+
*/
|
|
256
|
+
export function describeDataRefusal(err) {
|
|
257
|
+
const p = err?.problem
|
|
258
|
+
const reason = p?.reason
|
|
259
|
+
if (typeof reason !== 'string') return null
|
|
260
|
+
|
|
261
|
+
if (reason === 'storage_quota_exceeded') {
|
|
262
|
+
const notes = []
|
|
263
|
+
const used = humanBytes(p.used_bytes)
|
|
264
|
+
const limit = humanBytes(p.limit_bytes)
|
|
265
|
+
const needed = humanBytes(p.needed_bytes)
|
|
266
|
+
if (used) notes.push(` Used: ${used}`)
|
|
267
|
+
if (limit) notes.push(` Limit: ${limit}`)
|
|
268
|
+
if (needed) notes.push(` This publish adds: ${needed}`)
|
|
269
|
+
notes.push(
|
|
270
|
+
'Every publish declares the full set of schema-less data files, so removing'
|
|
271
|
+
)
|
|
272
|
+
notes.push(
|
|
273
|
+
'a collection and re-publishing stops it counting. Deleting a site or entity'
|
|
274
|
+
)
|
|
275
|
+
notes.push('frees space too.')
|
|
276
|
+
return {
|
|
277
|
+
headline:
|
|
278
|
+
"Storage quota reached — the site owner's workspace cannot take on more record data.",
|
|
279
|
+
notes
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// An unrecognised reason still beats a status dump: name it, and let the
|
|
284
|
+
// backend's own prose follow when it sent any.
|
|
285
|
+
const detail = typeof p.detail === 'string' ? p.detail : ''
|
|
286
|
+
return {
|
|
287
|
+
headline: `Record data upload refused by the backend (${reason}).`,
|
|
288
|
+
notes: detail ? [` ${detail}`] : []
|
|
289
|
+
}
|
|
290
|
+
}
|