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.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. 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
+ }