uniweb 1.0.0 → 1.0.2
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/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foundation code delivery — the phase-2 client of `uniweb register`.
|
|
3
|
+
*
|
|
4
|
+
* After a successful schema registration, the foundation's built `dist/`
|
|
5
|
+
* bytes are delivered to the registry in two steps (contract:
|
|
6
|
+
* foundation-code-upload.md, beside uwx-format.md):
|
|
7
|
+
*
|
|
8
|
+
* 1. PLAN — POST {apiBase}/dev/registry/code-uploads with the file list
|
|
9
|
+
* ({ path, content_type, size, sha256? }). The response carries
|
|
10
|
+
* one entry per file: `present: true` for a file the backend
|
|
11
|
+
* already stores for this version (no URL — it is skipped), or an
|
|
12
|
+
* upload target ({ path, method, url, headers }). Plus mode:
|
|
13
|
+
* 'direct' (dev — URLs point back at the backend) or 'presigned'
|
|
14
|
+
* (prod — storage PUTs; bytes never transit the backend). The CLI
|
|
15
|
+
* never branches on the mode.
|
|
16
|
+
* 2. UPLOAD — PUT each file's raw bytes to its URL with the given headers.
|
|
17
|
+
* The ENTRY uploads LAST: a partial upload never yields a
|
|
18
|
+
* loadable version (practical atomicity — there is no server
|
|
19
|
+
* confirm step by design).
|
|
20
|
+
*
|
|
21
|
+
* When the plan tells us where the version will be readable, the entry is
|
|
22
|
+
* fetched back from that location and compared byte-for-byte — the e2e proof
|
|
23
|
+
* that the version is live. The location is ALWAYS read from the plan's
|
|
24
|
+
* `serve_base`, never derived: where a backend serves uploaded code is its
|
|
25
|
+
* own business, and a producer that reconstructs the path is both coupled to
|
|
26
|
+
* it and wrong wherever a delivery tier mints the URL instead. No
|
|
27
|
+
* `serve_base` simply means "not verifiable from here" — not a fallback guess.
|
|
28
|
+
*
|
|
29
|
+
* Rules encoded here (the backend validates too — reject, never repair):
|
|
30
|
+
* - paths are dist/-relative, '/'-separated, URL-safe verbatim
|
|
31
|
+
* - `meta/**` is EXCLUDED from the upload set: schema custody is the
|
|
32
|
+
* registry's entity store, and the gateway serves anonymously while
|
|
33
|
+
* schemas are authenticated content everywhere else (no public catalog)
|
|
34
|
+
* - `*.map` sourcemaps are EXCLUDED: they are dev-only debugging artifacts,
|
|
35
|
+
* not part of the CDN-served runtime. (A Shiki-heavy foundation emits a
|
|
36
|
+
* map per highlighted-grammar chunk, which is what pushes a build past the
|
|
37
|
+
* plan step's per-version file cap — the cap is an abuse guard, the maps
|
|
38
|
+
* simply don't belong on the CDN.)
|
|
39
|
+
* - a registered version is immutable, code included — changed bytes mean
|
|
40
|
+
* a new version. A stored file is never re-sent (the plan marks it
|
|
41
|
+
* `present`), and one declared at a different size is refused (422)
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { createHash } from 'node:crypto'
|
|
45
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
46
|
+
import { join } from 'node:path'
|
|
47
|
+
import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
|
|
48
|
+
|
|
49
|
+
// Extension → declared content type. Extension-honest by construction (Vite
|
|
50
|
+
// output); anything unknown ships as octet-stream.
|
|
51
|
+
const CONTENT_TYPES = {
|
|
52
|
+
js: 'text/javascript',
|
|
53
|
+
mjs: 'text/javascript',
|
|
54
|
+
css: 'text/css',
|
|
55
|
+
map: 'application/json',
|
|
56
|
+
json: 'application/json',
|
|
57
|
+
wasm: 'application/wasm',
|
|
58
|
+
woff2: 'font/woff2',
|
|
59
|
+
woff: 'font/woff',
|
|
60
|
+
ttf: 'font/ttf',
|
|
61
|
+
png: 'image/png',
|
|
62
|
+
jpg: 'image/jpeg',
|
|
63
|
+
jpeg: 'image/jpeg',
|
|
64
|
+
gif: 'image/gif',
|
|
65
|
+
svg: 'image/svg+xml',
|
|
66
|
+
webp: 'image/webp',
|
|
67
|
+
avif: 'image/avif',
|
|
68
|
+
ico: 'image/x-icon',
|
|
69
|
+
txt: 'text/plain',
|
|
70
|
+
html: 'text/html'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function contentTypeFor(path) {
|
|
74
|
+
const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase()
|
|
75
|
+
return CONTENT_TYPES[ext] || 'application/octet-stream'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The dist-root entry file — uploaded last, verified after. */
|
|
79
|
+
export const ENTRY_PATH = 'entry.js'
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Walk a built dist/ and produce the upload file list.
|
|
83
|
+
* Excludes `meta/**` and `*.map` sourcemaps (see header). Paths are
|
|
84
|
+
* POSIX-relative to distDir.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} distDir
|
|
87
|
+
* @returns {Array<{ path: string, content_type: string, size: number, sha256: string }>}
|
|
88
|
+
*/
|
|
89
|
+
export function collectDistFiles(distDir) {
|
|
90
|
+
const files = []
|
|
91
|
+
const walk = (dir, prefix) => {
|
|
92
|
+
for (const name of readdirSync(dir).sort()) {
|
|
93
|
+
const full = join(dir, name)
|
|
94
|
+
const rel = prefix ? `${prefix}/${name}` : name
|
|
95
|
+
const st = statSync(full)
|
|
96
|
+
if (st.isDirectory()) {
|
|
97
|
+
if (rel === 'meta') continue // schema custody + no-public-catalog
|
|
98
|
+
walk(full, rel)
|
|
99
|
+
} else if (st.isFile()) {
|
|
100
|
+
if (rel.endsWith('.map')) continue // sourcemaps: dev-only, not CDN-served
|
|
101
|
+
const bytes = readFileSync(full)
|
|
102
|
+
files.push({
|
|
103
|
+
path: rel,
|
|
104
|
+
content_type: contentTypeFor(rel),
|
|
105
|
+
size: st.size,
|
|
106
|
+
sha256: createHash('sha256').update(bytes).digest('hex')
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
walk(distDir, '')
|
|
112
|
+
return files
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Entry-last upload order (practical atomicity — see header). */
|
|
116
|
+
export function uploadOrder(files) {
|
|
117
|
+
const entry = files.filter((f) => f.path === ENTRY_PATH)
|
|
118
|
+
const rest = files.filter((f) => f.path !== ENTRY_PATH)
|
|
119
|
+
return [...rest, ...entry]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The deterministic content digest of a built foundation — the freshness
|
|
124
|
+
* fingerprint `register` records and `publish`/`status` later compare against
|
|
125
|
+
* (shipping-model.md §4.1). It answers one question — "did the foundation change
|
|
126
|
+
* since it was released?" — with NO local state, so it has to be reproducible:
|
|
127
|
+
* the same source, built on any machine, must yield the same digest.
|
|
128
|
+
*
|
|
129
|
+
* Algorithm (§4.1): `sha256` over the newline-joined, SORTED list of the
|
|
130
|
+
* per-file `sha256` (hex) of exactly what `register` ships for a foundation —
|
|
131
|
+
* - the code-upload set (`collectDistFiles`: entry + chunks + assets, with
|
|
132
|
+
* `meta/**` and `*.map` already excluded), and
|
|
133
|
+
* - the schema source (`meta/schema.json`) — NOT uploaded as a file, and not
|
|
134
|
+
* sent verbatim: register submits its *content* as the foundation-schema
|
|
135
|
+
* ENTITY in the `.uwx`. It's read here only to fold the schema into the
|
|
136
|
+
* local freshness fingerprint (so a schema edit shows as "changed").
|
|
137
|
+
* Hashes are SORTED (so the digest is order-independent) and taken over file
|
|
138
|
+
* CONTENTS, not names, so content-hashed chunk filenames don't perturb it.
|
|
139
|
+
* Returns null when there's nothing to hash (no usable dist), so callers skip
|
|
140
|
+
* cleanly rather than fingerprinting emptiness.
|
|
141
|
+
*
|
|
142
|
+
* The result is OPAQUE to the backend — it stores the string and returns it
|
|
143
|
+
* verbatim; the framework owns the algorithm end-to-end and can change it
|
|
144
|
+
* freely (a digest only ever compares against one the same CLI computed).
|
|
145
|
+
*
|
|
146
|
+
* @param {string} distDir - the built `dist/` directory
|
|
147
|
+
* @returns {string|null} `sha256:<hex>` or null when there's nothing to hash
|
|
148
|
+
*/
|
|
149
|
+
/**
|
|
150
|
+
* The `@uniweb/runtime` version a built foundation linked against, from
|
|
151
|
+
* `dist/runtime-pin.json`.
|
|
152
|
+
*
|
|
153
|
+
* ⭐ **This is the ONLY reader of that file, and it exists so the pin can reach
|
|
154
|
+
* a party that can act on it.** The pin is a compatibility FLOOR — the lowest
|
|
155
|
+
* runtime this build is known to work against. A site loads a primary
|
|
156
|
+
* foundation plus N extensions, so a site's floor is `max()` over all of their
|
|
157
|
+
* pins, and the producer publishing one foundation cannot compute that: it does
|
|
158
|
+
* not hold the others. Whoever holds every foundation a site loads can.
|
|
159
|
+
*
|
|
160
|
+
* So the split is: the producer STATES its own floor, and a party that resolves
|
|
161
|
+
* a whole site CHECKS them. Riding it in the identity card next to `digest` is
|
|
162
|
+
* what makes the first half possible — and until it did, a consumer storing the
|
|
163
|
+
* value had no way to ever receive one.
|
|
164
|
+
*
|
|
165
|
+
* Returns null when the pin is absent (the build could not resolve
|
|
166
|
+
* `@uniweb/runtime`) or malformed. ⚠️ **null means UNKNOWN, not unconstrained** —
|
|
167
|
+
* a floor that is absent cannot be proven satisfied by anything, and a
|
|
168
|
+
* consumer's `max()` that skips nulls silently computes a floor over only the
|
|
169
|
+
* foundations that happened to declare one.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} distDir - the built `dist/` directory
|
|
172
|
+
* @returns {string|null} e.g. `'0.9.8'`
|
|
173
|
+
*/
|
|
174
|
+
export function readRuntimePin(distDir) {
|
|
175
|
+
try {
|
|
176
|
+
const pin = JSON.parse(readFileSync(join(distDir, 'runtime-pin.json'), 'utf8'))
|
|
177
|
+
return typeof pin?.runtime === 'string' && pin.runtime ? pin.runtime : null
|
|
178
|
+
} catch {
|
|
179
|
+
return null // absent or unparseable — both are "unknown"
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function computeFoundationDigest(distDir) {
|
|
184
|
+
const hashes = collectDistFiles(distDir).map((f) => f.sha256)
|
|
185
|
+
// The schema rides in the register `.uwx`, not the code-upload set, so fold
|
|
186
|
+
// it in explicitly — a schema-only change is still a foundation change.
|
|
187
|
+
const schemaPath = join(distDir, 'meta', 'schema.json')
|
|
188
|
+
if (existsSync(schemaPath)) {
|
|
189
|
+
hashes.push(
|
|
190
|
+
createHash('sha256').update(readFileSync(schemaPath)).digest('hex')
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
if (!hashes.length) return null
|
|
194
|
+
hashes.sort()
|
|
195
|
+
return (
|
|
196
|
+
'sha256:' + createHash('sha256').update(hashes.join('\n')).digest('hex')
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/*
|
|
201
|
+
* There is deliberately no serve-URL builder here. A previous version exported
|
|
202
|
+
* `gatewayUrl()`, which reconstructed the backend's serve path from the ref —
|
|
203
|
+
* mirroring its storage convention, scope-without-the-'@' and all. That was
|
|
204
|
+
* wrong twice over: it encoded another service's route layout in a public
|
|
205
|
+
* package, and it can only ever be right on the one deployment shape where the
|
|
206
|
+
* backend also serves the bytes. Read `serve_base` from the upload plan.
|
|
207
|
+
*/
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Deliver a foundation's code: plan, upload (entry last), verify (direct
|
|
211
|
+
* mode). Returns a result object; throws only on plan-level failures.
|
|
212
|
+
*
|
|
213
|
+
* @param {object} opts
|
|
214
|
+
* @param {string} opts.apiBase - registry origin (e.g. http://localhost:8080)
|
|
215
|
+
* @param {string} opts.token - bearer (same session as register)
|
|
216
|
+
* @param {string} opts.name - '@scope/name'
|
|
217
|
+
* @param {string} opts.version - the registered semver
|
|
218
|
+
* @param {string} opts.distDir - the built dist/ directory
|
|
219
|
+
* @param {Array} [opts.files] - pre-collected file list (default: collect)
|
|
220
|
+
* @param {(msg: string) => void} [opts.onProgress]
|
|
221
|
+
* @returns {Promise<{ mode: string, uploaded: string[], stored: string[], failed: Array<{path, status, detail}>, verified: boolean|null, serveBase: string|null }>}
|
|
222
|
+
* `stored` — files the plan reported as already stored (`present: true`), not re-sent.
|
|
223
|
+
* @throws {Error} when the plan is refused — with `status`, and the problem+json
|
|
224
|
+
* `detail` and `code` when the body carries them
|
|
225
|
+
*/
|
|
226
|
+
export async function uploadFoundationCode({
|
|
227
|
+
apiBase,
|
|
228
|
+
token,
|
|
229
|
+
name,
|
|
230
|
+
version,
|
|
231
|
+
distDir,
|
|
232
|
+
files,
|
|
233
|
+
onProgress = () => {}
|
|
234
|
+
}) {
|
|
235
|
+
const list = files || collectDistFiles(distDir)
|
|
236
|
+
if (!list.length) {
|
|
237
|
+
return {
|
|
238
|
+
mode: 'none',
|
|
239
|
+
uploaded: [],
|
|
240
|
+
failed: [],
|
|
241
|
+
verified: null,
|
|
242
|
+
serveBase: null
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const origin = apiBase.replace(/\/$/, '')
|
|
247
|
+
const planRes = await fetch(`${origin}/dev/registry/code-uploads`, {
|
|
248
|
+
method: 'POST',
|
|
249
|
+
headers: {
|
|
250
|
+
'Content-Type': 'application/json',
|
|
251
|
+
Authorization: `Bearer ${token}`
|
|
252
|
+
},
|
|
253
|
+
body: JSON.stringify({
|
|
254
|
+
name,
|
|
255
|
+
version,
|
|
256
|
+
files: list.map(({ path, content_type, size, sha256 }) => ({
|
|
257
|
+
path,
|
|
258
|
+
content_type,
|
|
259
|
+
size,
|
|
260
|
+
// Optional integrity hint (ignored by the v1 backend; flows so a
|
|
261
|
+
// future checksum-bearing presign needs no CLI change).
|
|
262
|
+
sha256
|
|
263
|
+
}))
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
if (!planRes.ok) {
|
|
267
|
+
const body = await planRes.text().catch(() => '')
|
|
268
|
+
// A refusal is problem+json, and its `detail` is a sentence written for the
|
|
269
|
+
// person reading — for a changed file it says what to do. Carry it (and the
|
|
270
|
+
// `code`) instead of a raw body the reader has to dig the sentence out of.
|
|
271
|
+
let problem = null
|
|
272
|
+
try {
|
|
273
|
+
problem = JSON.parse(body)
|
|
274
|
+
} catch {
|
|
275
|
+
problem = null
|
|
276
|
+
}
|
|
277
|
+
const detail = typeof problem?.detail === 'string' ? problem.detail : null
|
|
278
|
+
const err = new Error(
|
|
279
|
+
`code-uploads plan rejected: HTTP ${planRes.status}${detail ? ` — ${detail}` : body ? ` — ${body.slice(0, 300)}` : ''}`
|
|
280
|
+
)
|
|
281
|
+
err.status = planRes.status
|
|
282
|
+
err.detail = detail
|
|
283
|
+
err.code = typeof problem?.code === 'string' ? problem.code : null
|
|
284
|
+
throw err
|
|
285
|
+
}
|
|
286
|
+
const plan = await planRes.json()
|
|
287
|
+
const targets = new Map((plan.uploads || []).map((u) => [u.path, u]))
|
|
288
|
+
const serveBase = plan.serve_base || null
|
|
289
|
+
// The ONE mode-aware bit: direct-mode PUTs are bearer-authed backend
|
|
290
|
+
// routes; presigned URLs are self-authorizing and must NOT carry a
|
|
291
|
+
// bearer (foreign auth headers can break signed-request validation).
|
|
292
|
+
const authHeaders =
|
|
293
|
+
plan.mode === 'direct' ? { Authorization: `Bearer ${token}` } : {}
|
|
294
|
+
|
|
295
|
+
const uploaded = []
|
|
296
|
+
const stored = []
|
|
297
|
+
const failed = []
|
|
298
|
+
for (const file of uploadOrder(list)) {
|
|
299
|
+
const target = targets.get(file.path)
|
|
300
|
+
if (!target) {
|
|
301
|
+
failed.push({
|
|
302
|
+
path: file.path,
|
|
303
|
+
status: 0,
|
|
304
|
+
detail: 'no upload target in plan'
|
|
305
|
+
})
|
|
306
|
+
continue
|
|
307
|
+
}
|
|
308
|
+
// ⭐ A file the backend already stores for this version comes back
|
|
309
|
+
// `present: true`, with no URL — a stored file of a registered version is
|
|
310
|
+
// never overwritten. Skip it. This is what lets an interrupted upload
|
|
311
|
+
// resume, and a re-run on a fully uploaded version pass.
|
|
312
|
+
//
|
|
313
|
+
// ⛔ Before this branch existed, a present entry was PUT to
|
|
314
|
+
// `new URL(undefined, origin)`, which does not throw — it resolves to
|
|
315
|
+
// `<origin>/undefined` — and came back as a failed upload.
|
|
316
|
+
if (target.present === true) {
|
|
317
|
+
stored.push(file.path)
|
|
318
|
+
onProgress(`${file.path} (already stored)`)
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
if (!target.url) {
|
|
322
|
+
failed.push({
|
|
323
|
+
path: file.path,
|
|
324
|
+
status: 0,
|
|
325
|
+
detail: 'the plan gave no upload URL'
|
|
326
|
+
})
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
const bytes = readFileSync(join(distDir, file.path))
|
|
330
|
+
try {
|
|
331
|
+
// ⭐ **Retried.** A single connection-level failure used to fail the whole
|
|
332
|
+
// verb after every other file had already gone up. The PUT is idempotent
|
|
333
|
+
// here — same bytes, same target, verified by `x-uniweb-sha256` on receipt
|
|
334
|
+
// — so repeating it is safe by construction, which is why this opts in to
|
|
335
|
+
// `retryOnStatus` rather than inheriting it.
|
|
336
|
+
//
|
|
337
|
+
// ⚠️ 120s per attempt, not the helper's 30s default: this bounds the time
|
|
338
|
+
// to send a whole BODY, and aborting a legitimately slow upload only to
|
|
339
|
+
// retry it is worse than not timing out at all.
|
|
340
|
+
const res = await fetchWithRetry(new URL(target.url, origin), {
|
|
341
|
+
method: target.method || 'PUT',
|
|
342
|
+
// x-uniweb-sha256: optional integrity guard — direct mode verifies
|
|
343
|
+
// the received bytes and 400s on mismatch (corruption-in-flight).
|
|
344
|
+
headers: {
|
|
345
|
+
...(target.headers || {}),
|
|
346
|
+
...authHeaders,
|
|
347
|
+
'x-uniweb-sha256': file.sha256
|
|
348
|
+
},
|
|
349
|
+
body: bytes
|
|
350
|
+
}, { timeoutMs: 120_000, retryOnStatus: isTransientStatus })
|
|
351
|
+
if (res.ok) {
|
|
352
|
+
uploaded.push(file.path)
|
|
353
|
+
onProgress(`${file.path} (${file.size} bytes)`)
|
|
354
|
+
} else {
|
|
355
|
+
failed.push({
|
|
356
|
+
path: file.path,
|
|
357
|
+
status: res.status,
|
|
358
|
+
detail: (await res.text().catch(() => '')).slice(0, 200)
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
} catch (err) {
|
|
362
|
+
failed.push({ path: file.path, status: 0, detail: err.message })
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Prove the version is live — fetch the entry back and compare bytes.
|
|
367
|
+
// Gated on `serveBase`, NOT on the plan's mode: the question is only ever
|
|
368
|
+
// "did the backend tell us where to look", and inferring a location from the
|
|
369
|
+
// mode is the coupling this lane used to carry. A plan without a serve_base
|
|
370
|
+
// (a delivery tier owns the URL) leaves `verified` null.
|
|
371
|
+
let verified = null
|
|
372
|
+
const entry = list.find((f) => f.path === ENTRY_PATH)
|
|
373
|
+
if (serveBase && entry && !failed.length) {
|
|
374
|
+
try {
|
|
375
|
+
// serve_base may be origin-relative — resolve against the backend origin.
|
|
376
|
+
const url = new URL(
|
|
377
|
+
`${serveBase.replace(/\/$/, '')}/${ENTRY_PATH}`,
|
|
378
|
+
origin
|
|
379
|
+
).toString()
|
|
380
|
+
const res = await fetch(url)
|
|
381
|
+
if (res.ok) {
|
|
382
|
+
const served = Buffer.from(await res.arrayBuffer())
|
|
383
|
+
const local = readFileSync(join(distDir, ENTRY_PATH))
|
|
384
|
+
verified = served.equals(local)
|
|
385
|
+
} else {
|
|
386
|
+
verified = false
|
|
387
|
+
}
|
|
388
|
+
} catch {
|
|
389
|
+
verified = false
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return { mode: plan.mode || 'direct', uploaded, stored, failed, verified, serveBase }
|
|
394
|
+
}
|