uniweb 0.37.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.37.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": {
@@ -42,14 +42,14 @@
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/core": "^0.16.0",
45
- "@uniweb/kit": "^0.15.2",
45
+ "@uniweb/kit": "^0.15.3",
46
46
  "@uniweb/runtime": "^0.13.5",
47
47
  "@uniweb/semantic-parser": "^1.4.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "@uniweb/build": "^0.33.0",
51
- "@uniweb/content-reader": "^1.2.4",
52
- "@uniweb/semantic-parser": "^1.4.0"
51
+ "@uniweb/semantic-parser": "^1.4.0",
52
+ "@uniweb/content-reader": "^1.2.4"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-02T16:36:26.382Z",
3
+ "generatedAt": "2026-09-02T17:42:38.924Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.2.2",
@@ -54,7 +54,7 @@
54
54
  ]
55
55
  },
56
56
  "@uniweb/kit": {
57
- "version": "0.15.2",
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.19",
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
- * Fetch with retry and timeout
83
- */
84
- async function fetchWithRetry(url, options = {}, maxRetries = 3) {
85
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
86
- try {
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
- * Fetch with retry and timeout
81
- */
82
- async function fetchWithRetry(url, options = {}, maxRetries = 3) {
83
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
84
- try {
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
- * Fetch with retry and timeout
227
- */
228
- async function fetchWithRetry(url, options = {}, maxRetries = 3) {
229
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
230
- try {
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
- putRes = await fetch(new URL(up.url, origin), {
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
@@ -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
- const res = await fetch(new URL(target.url, origin), {
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)`)
@@ -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
@@ -52,6 +52,7 @@
52
52
 
53
53
  import { createHash } from 'node:crypto'
54
54
  import { humanBytes } from './bytes.js'
55
+ import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
55
56
 
56
57
  /**
57
58
  * Plan + upload a site's static collection data files.
@@ -189,7 +190,16 @@ export async function uploadSiteData({
189
190
  try {
190
191
  // Relative on the direct arm, absolute on presigned — `new URL` resolves
191
192
  // both against the origin.
192
- const res = await fetch(new URL(target.url, origin), {
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), {
193
203
  method: target.method || 'PUT',
194
204
  headers: {
195
205
  'Content-Type': 'application/json',
@@ -197,7 +207,7 @@ export async function uploadSiteData({
197
207
  ...authHeaders
198
208
  },
199
209
  body: f.bytes
200
- })
210
+ }, { timeoutMs: 120_000, retryOnStatus: isTransientStatus })
201
211
  if (res.ok) {
202
212
  uploaded.push(f.path)
203
213
  onProgress(`${f.path}`)