uniweb 0.25.2 → 0.25.3
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 +5 -5
- package/src/commands/publish.js +55 -24
- package/src/framework-index.json +2 -2
- package/src/utils/site-data-upload.js +176 -0
- package/src/backend/data-bundle.js +0 -85
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.3",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,13 +43,13 @@
|
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
44
|
"@uniweb/core": "^0.10.1",
|
|
45
45
|
"@uniweb/kit": "^0.12.3",
|
|
46
|
-
"@uniweb/
|
|
47
|
-
"@uniweb/
|
|
46
|
+
"@uniweb/runtime": "^0.12.2",
|
|
47
|
+
"@uniweb/semantic-parser": "^1.2.3"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/
|
|
50
|
+
"@uniweb/build": "^0.24.3",
|
|
51
51
|
"@uniweb/content-reader": "^1.2.3",
|
|
52
|
-
"@uniweb/
|
|
52
|
+
"@uniweb/semantic-parser": "^1.2.3"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
package/src/commands/publish.js
CHANGED
|
@@ -53,9 +53,9 @@ import {
|
|
|
53
53
|
loadDeployYml,
|
|
54
54
|
resolveTarget,
|
|
55
55
|
recordLastDeploy,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
collectSchemalessData,
|
|
57
|
+
collectSchemalessDataAssets,
|
|
58
|
+
rewriteSchemalessDataAssets
|
|
59
59
|
} from '@uniweb/build/site'
|
|
60
60
|
import { emitSyncPackages } from '@uniweb/build/uwx'
|
|
61
61
|
import { isSiteRelativeExtensionUrl } from '@uniweb/build'
|
|
@@ -79,7 +79,6 @@ import {
|
|
|
79
79
|
pushSyncPackages,
|
|
80
80
|
resolveSiteOrgForCreate
|
|
81
81
|
} from '../backend/site-sync.js'
|
|
82
|
-
import { uploadDataBundle } from '../backend/data-bundle.js'
|
|
83
82
|
import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
|
|
84
83
|
import { updateAssetMap, ASSET_MAP_FILE } from '@uniweb/build/uwx'
|
|
85
84
|
import {
|
|
@@ -88,6 +87,7 @@ import {
|
|
|
88
87
|
} from '../backend/foundation-bring-along.js'
|
|
89
88
|
import { settlePaymentIfNeeded } from '../backend/payment-handoff.js'
|
|
90
89
|
import { reportSchemalessCollections } from '../utils/schemaless-report.js'
|
|
90
|
+
import { uploadSiteData } from '../utils/site-data-upload.js'
|
|
91
91
|
|
|
92
92
|
const c = {
|
|
93
93
|
reset: '\x1b[0m',
|
|
@@ -527,8 +527,8 @@ export async function publish(args = []) {
|
|
|
527
527
|
|
|
528
528
|
// 4. Assemble the static-data ball (schema-less data + search index) BEFORE
|
|
529
529
|
// uploading, since its records can carry local media too.
|
|
530
|
-
let ball = await
|
|
531
|
-
const ballAssets =
|
|
530
|
+
let ball = await collectSchemalessData(distDir, schemalessNames)
|
|
531
|
+
const ballAssets = collectSchemalessDataAssets(ball)
|
|
532
532
|
|
|
533
533
|
// 4b. Upload ALL local media (entity refs + ball refs) on one asset lane →
|
|
534
534
|
// the ref→serveUrl map; rewrite the entity content AND the ball with it.
|
|
@@ -567,7 +567,7 @@ export async function publish(args = []) {
|
|
|
567
567
|
`${ASSET_MAP_FILE} : ${rec.added.length} added, ${rec.changed.length} changed — commit it`
|
|
568
568
|
)
|
|
569
569
|
}
|
|
570
|
-
if (ballAssets.length) ball =
|
|
570
|
+
if (ballAssets.length) ball = rewriteSchemalessDataAssets(ball, map)
|
|
571
571
|
say.dim(
|
|
572
572
|
`Media : ${Object.keys(map).length}/${mediaRefs.length} ref(s) → serve URL`
|
|
573
573
|
)
|
|
@@ -586,34 +586,56 @@ export async function publish(args = []) {
|
|
|
586
586
|
}
|
|
587
587
|
}
|
|
588
588
|
|
|
589
|
-
// 4c.
|
|
590
|
-
|
|
589
|
+
// 4c. Deliver the schema-less collection data — one object per file.
|
|
590
|
+
//
|
|
591
|
+
// Each `dist/data/**` file is PUT to the target `data-uploads` returns,
|
|
592
|
+
// landing at its serving tail. Hosting intercepts `/data/**.json` and
|
|
593
|
+
// reads `_data/{tail}` from the site bucket, so the file is served from
|
|
594
|
+
// where it lands: **nothing records where anything went**, and no `info`
|
|
595
|
+
// field is stamped.
|
|
596
|
+
//
|
|
597
|
+
// ⛔ NO BALL. A CLI sends separates or a ball, never both — presence of a
|
|
598
|
+
// ball is the backend's signal for which CLI it is talking to, and
|
|
599
|
+
// sending both destroys it along with their ability to know when the
|
|
600
|
+
// unwrap can be deleted. Released CLIs keep sending one and their unwrap
|
|
601
|
+
// keeps serving them; this one does not.
|
|
602
|
+
//
|
|
603
|
+
// 📌 No capability gate, deliberately. A backend predating this lane
|
|
604
|
+
// answers 404 and the publish fails — accepted while pre-prod [Diego,
|
|
605
|
+
// 2026-08-18: "we are pre-prod. I'm not concerned about old cli vs new"].
|
|
606
|
+
// A gate was written and removed: it is machinery for a population that
|
|
607
|
+
// does not exist, which is the failure this work kept catching in others.
|
|
608
|
+
// `client.discover()` is the mechanism if that changes — `DISCOVERY_DEFAULTS`
|
|
609
|
+
// makes an absent key non-breaking by construction.
|
|
591
610
|
if (ball) {
|
|
592
|
-
say.info('Uploading data
|
|
611
|
+
say.info('Uploading collection data…')
|
|
593
612
|
try {
|
|
594
|
-
|
|
613
|
+
const r = await uploadSiteData({
|
|
614
|
+
apiBase: client.origin,
|
|
615
|
+
token: await client.token(),
|
|
595
616
|
siteUuid: site.uuid,
|
|
617
|
+
ball,
|
|
596
618
|
onProgress: (m) => say.dim(` ${m}`)
|
|
597
619
|
})
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
say.
|
|
603
|
-
|
|
604
|
-
} else {
|
|
605
|
-
say.err(`Data bundle upload failed: ${err.message}`)
|
|
620
|
+
if (r.failed.length) {
|
|
621
|
+
// A file whose bytes did not land must not be published: the site would
|
|
622
|
+
// serve a stale copy or 404, and the only trace would be a warning.
|
|
623
|
+
say.err(`${r.failed.length} data file(s) failed to upload — not publishing.`)
|
|
624
|
+
for (const f of r.failed) say.dim(` ${f.path} (HTTP ${f.status})`)
|
|
625
|
+
return { exitCode: 1 }
|
|
606
626
|
}
|
|
627
|
+
say.dim(`Collection data : ${r.uploaded.length} file(s) [${r.mode}]`)
|
|
628
|
+
} catch (err) {
|
|
629
|
+
say.err(`Collection data upload failed: ${err.message}`)
|
|
607
630
|
return { exitCode: 1 }
|
|
608
631
|
}
|
|
609
|
-
say.dim(
|
|
610
|
-
`Data bundle : ${Object.keys(ball.data).length} data + ${Object.keys(ball.search).length} search file(s)`
|
|
611
|
-
)
|
|
612
632
|
}
|
|
613
633
|
|
|
614
634
|
// 5. Push the site (content + folder) over the send-only-changed cache —
|
|
615
635
|
// the SAME two-lane submission `uniweb push` uses — stamping
|
|
616
|
-
//
|
|
636
|
+
// the pinned foundation ref and rewriting local media refs to serve URLs.
|
|
637
|
+
// (It stamped `info.data_bundle` until 2026-08-18; the ball is gone and
|
|
638
|
+
// collection data now lands at its serving tail, so nothing records it.)
|
|
617
639
|
const priorHashes = readSyncCache(siteDir)
|
|
618
640
|
// publish rides the same gated push as `uniweb push`: if an app author has
|
|
619
641
|
// edited since this clone last synced, the push is refused rather than
|
|
@@ -635,8 +657,17 @@ export async function publish(args = []) {
|
|
|
635
657
|
// released version on the wire is required when site.yml uses an unversioned
|
|
636
658
|
// local ref; injectInfo overrides info.foundation. A registry/URL ref → fnd.ref
|
|
637
659
|
// is null → the site.yml ref is forwarded verbatim (already pinned).
|
|
660
|
+
// ⛔ DO NOT STAMP `info.data` HERE. The name is TAKEN: `uwx/site.js` already
|
|
661
|
+
// emits `info.data` from `site.yml`'s top-level `data:`/`fetch:` block, and
|
|
662
|
+
// `injectInfo` WINS the merge (`sync-package.js`: `{...siteDoc.info,
|
|
663
|
+
// ...injectInfo}`), so stamping a file map here silently replaces the author's
|
|
664
|
+
// fetch config on the wire. Both are `type: json`, so the store validator
|
|
665
|
+
// accepts either and nothing errors at any layer.
|
|
666
|
+
//
|
|
667
|
+
// A file map needs a name nothing else claims (`static_data` / `data_files`
|
|
668
|
+
// were proposed) AND a consumer that reads it — neither settled. See
|
|
669
|
+
// `kb/framework/build/data-ball-retirement.md`.
|
|
638
670
|
const injectInfo = {
|
|
639
|
-
...(dataBundle ? { data_bundle: dataBundle } : {}),
|
|
640
671
|
...(fnd.ref ? { foundation: fnd.ref } : {})
|
|
641
672
|
}
|
|
642
673
|
let pkg
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-08-
|
|
3
|
+
"generatedAt": "2026-08-18T22:18:28.572Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.24.
|
|
6
|
+
"version": "0.24.3",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static collection data — the passthrough lane.
|
|
3
|
+
*
|
|
4
|
+
* A site's **schema-less** collections have no entity model, so their compiled
|
|
5
|
+
* `dist/data/**` JSON is delivered as files rather than synced as entities.
|
|
6
|
+
* This plans and uploads that set: one plan call, one object per file, each
|
|
7
|
+
* PUT to the target the backend returns.
|
|
8
|
+
*
|
|
9
|
+
* 1. PLAN — POST {apiBase}/dev/site/data-uploads/{site} with the file list
|
|
10
|
+
* ({ path, content_type, size, sha256 }). Response carries
|
|
11
|
+
* `mode` ('presigned' | 'direct'), `expires_in`, `serve_base`,
|
|
12
|
+
* and one `uploads` entry per file ({ path, method, url, headers }).
|
|
13
|
+
* 2. UPLOAD — PUT each file's bytes to its URL. Order is irrelevant; there is
|
|
14
|
+
* no entry file and no server confirm step.
|
|
15
|
+
*
|
|
16
|
+
* ⭐ **`path` is the SERVING TAIL** (`data/articles.json`), not a bookkeeping
|
|
17
|
+
* key. The file lands where it is served — which is the whole point of this
|
|
18
|
+
* lane, and why it returns no map: nothing has to record where anything went.
|
|
19
|
+
*
|
|
20
|
+
* ## Why this is not the asset lane
|
|
21
|
+
*
|
|
22
|
+
* Assets are **global and content-addressed** — identical bytes dedup across
|
|
23
|
+
* sites — so the relation from a site's path to an object is many-to-one and a
|
|
24
|
+
* serve path cannot be a property of the object. This lane is **per-site and
|
|
25
|
+
* path-addressed**. Sending static data through the asset store is what forced
|
|
26
|
+
* the old "data ball": one bundled asset the backend had to fetch, parse and
|
|
27
|
+
* fan out, which is the only place these bytes ever transited it.
|
|
28
|
+
*
|
|
29
|
+
* ⇒ **The backend is a PASSTHROUGH here** — it brokers the authorization, not
|
|
30
|
+
* the bytes. Hosting static content is the hosting platform's mission where an
|
|
31
|
+
* edge exists; where there is none (edgeless), the backend serves them itself,
|
|
32
|
+
* and that is the posture working rather than a fallback.
|
|
33
|
+
*
|
|
34
|
+
* ⛔ **Do not compose paths onto `serve_base`.** It is `null` on the presigned
|
|
35
|
+
* arm (prod serving is the delivery tier's URL shape, not the backend's to
|
|
36
|
+
* mint) and the gateway's data route on the direct arm. Nothing in a build
|
|
37
|
+
* needs it — the site's fetcher asks for a site-relative `/data/{path}` at
|
|
38
|
+
* runtime — so it is returned for parity and for a caller that wants to verify
|
|
39
|
+
* an upload landed.
|
|
40
|
+
*
|
|
41
|
+
* Contract ratified 2026-08-18; see `kb/framework/build/data-ball-retirement.md`.
|
|
42
|
+
* ⚠️ **The endpoint is not built yet** — this is unwired until it is.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { createHash } from 'node:crypto'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Plan + upload a site's static collection data files.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} opts
|
|
51
|
+
* @param {string} opts.apiBase - backend origin
|
|
52
|
+
* @param {string} opts.token - bearer, used on the direct arm only
|
|
53
|
+
* @param {string} opts.siteUuid - the site these files belong to (path segment)
|
|
54
|
+
* @param {{ data: Record<string, unknown> }|null} opts.ball - source of the set:
|
|
55
|
+
* `{ "<relpath under dist/data>": <json> }`, media refs already rewritten
|
|
56
|
+
* @param {(m: string) => void} [opts.onProgress]
|
|
57
|
+
* @returns {Promise<{ mode: string, uploaded: string[], failed: Array<{path:string,status:number,detail?:string}>, serveBase: string|null }>}
|
|
58
|
+
* **No map** — by design. The files are at their serving paths.
|
|
59
|
+
*/
|
|
60
|
+
export async function uploadSiteData({
|
|
61
|
+
apiBase,
|
|
62
|
+
token,
|
|
63
|
+
siteUuid,
|
|
64
|
+
ball,
|
|
65
|
+
onProgress = () => {}
|
|
66
|
+
}) {
|
|
67
|
+
const entries = Object.entries(ball?.data || {})
|
|
68
|
+
if (!entries.length) {
|
|
69
|
+
return { mode: 'none', uploaded: [], failed: [], serveBase: null }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// One plan for the whole set. The per-request file cap counts a plan, so
|
|
73
|
+
// splitting would evade it rather than respect it; if a set ever exceeds it,
|
|
74
|
+
// the refusal is the correct outcome and the cap is the thing to fix.
|
|
75
|
+
const files = entries.map(([relPath, value]) => {
|
|
76
|
+
const bytes = Buffer.from(JSON.stringify(value))
|
|
77
|
+
return {
|
|
78
|
+
// ⚠️ The tail RELATIVE TO THE DATA ROOT — no `data/` prefix.
|
|
79
|
+
//
|
|
80
|
+
// The plan's `serve_base` already ends in `/data/`
|
|
81
|
+
// (`/gateway/site/{site}/data/`), and the runtime asks for
|
|
82
|
+
// `{config.base}/data/{name}.json` (`DATA_URL_PREFIX`). Sending
|
|
83
|
+
// `data/articles.json` would store one level too deep and serve at
|
|
84
|
+
// `…/data/data/articles.json`, which the runtime never requests.
|
|
85
|
+
//
|
|
86
|
+
// ⛔ And the failure is INVISIBLE from here: the plan succeeds, the PUT
|
|
87
|
+
// succeeds, and only a visitor's fetch 404s. Confirmed against the
|
|
88
|
+
// shipped contract's own example (`collections/articles.json` with a
|
|
89
|
+
// `/data/`-bearing `serve_base`), which supersedes an earlier
|
|
90
|
+
// parenthetical that showed the prefix.
|
|
91
|
+
path: relPath,
|
|
92
|
+
content_type: 'application/json',
|
|
93
|
+
size: bytes.length,
|
|
94
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
95
|
+
bytes
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const origin = apiBase.replace(/\/$/, '')
|
|
100
|
+
// `{site}` is a PATH segment, not a body field — every sibling on this
|
|
101
|
+
// controller addresses the site that way (`content/push/{site}`,
|
|
102
|
+
// `publish/{site}`, `status/{site}`). The asset lane's body-borne owner is
|
|
103
|
+
// the wrong precedent: that lane is global, this one is per-site.
|
|
104
|
+
const planRes = await fetch(
|
|
105
|
+
`${origin}/dev/site/data-uploads/${encodeURIComponent(siteUuid)}`,
|
|
106
|
+
{
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: {
|
|
109
|
+
'Content-Type': 'application/json',
|
|
110
|
+
Authorization: `Bearer ${token}`
|
|
111
|
+
},
|
|
112
|
+
body: JSON.stringify({
|
|
113
|
+
files: files.map(({ path, content_type, size, sha256 }) => ({
|
|
114
|
+
path,
|
|
115
|
+
content_type,
|
|
116
|
+
size,
|
|
117
|
+
sha256
|
|
118
|
+
}))
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
if (!planRes.ok) {
|
|
123
|
+
const body = await planRes.text().catch(() => '')
|
|
124
|
+
throw new Error(
|
|
125
|
+
`site data-uploads plan rejected: HTTP ${planRes.status}${body ? ` — ${body.slice(0, 300)}` : ''}`
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const plan = await planRes.json()
|
|
130
|
+
const targets = new Map((plan.uploads || []).map((u) => [u.path, u]))
|
|
131
|
+
// The one mode-aware bit, same rule as the code and runtime lanes: a direct
|
|
132
|
+
// PUT is a bearer-authed backend route; a presigned URL is self-authorizing
|
|
133
|
+
// and must NOT carry a foreign bearer, which can break signature validation.
|
|
134
|
+
const authHeaders =
|
|
135
|
+
plan.mode === 'presigned' ? {} : { Authorization: `Bearer ${token}` }
|
|
136
|
+
|
|
137
|
+
const uploaded = []
|
|
138
|
+
const failed = []
|
|
139
|
+
for (const f of files) {
|
|
140
|
+
const target = targets.get(f.path)
|
|
141
|
+
if (!target) {
|
|
142
|
+
// A file the plan did not answer for is unaddressable. Report it; never
|
|
143
|
+
// invent a location for it.
|
|
144
|
+
failed.push({ path: f.path, status: 0, detail: 'no upload target in plan' })
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
// Relative on the direct arm, absolute on presigned — `new URL` resolves
|
|
149
|
+
// both against the origin.
|
|
150
|
+
const res = await fetch(new URL(target.url, origin), {
|
|
151
|
+
method: target.method || 'PUT',
|
|
152
|
+
headers: {
|
|
153
|
+
'Content-Type': 'application/json',
|
|
154
|
+
...(target.headers || {}),
|
|
155
|
+
...authHeaders
|
|
156
|
+
},
|
|
157
|
+
body: f.bytes
|
|
158
|
+
})
|
|
159
|
+
if (res.ok) {
|
|
160
|
+
uploaded.push(f.path)
|
|
161
|
+
onProgress(`${f.path}`)
|
|
162
|
+
} else {
|
|
163
|
+
failed.push({ path: f.path, status: res.status })
|
|
164
|
+
}
|
|
165
|
+
} catch (err) {
|
|
166
|
+
failed.push({ path: f.path, status: 0, detail: err.message })
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
mode: plan.mode || 'direct',
|
|
172
|
+
uploaded,
|
|
173
|
+
failed,
|
|
174
|
+
serveBase: plan.serve_base || null
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Upload the static-data ball (assembleDataBall's `{ data, search }` doc) to the
|
|
3
|
-
* backend's content-addressed asset store via the SAME asset lane deploy uses for
|
|
4
|
-
* media, and return its durable serve URL — the `info.data_bundle` the composite push
|
|
5
|
-
* stamps on the site-content entity. The backend unwraps the ball into the `/data/*`
|
|
6
|
-
* + `/_search/*` bytes the gateway serves.
|
|
7
|
-
*
|
|
8
|
-
* The ball is in-memory (not a built file on disk), so it rides as `bytes` on the
|
|
9
|
-
* single upload entry — `uploadSiteAssets` PUTs `bytes` when present, else reads a
|
|
10
|
-
* `diskPath` (its media path). Content-addressed like every asset: identical ball →
|
|
11
|
-
* same id → a re-deploy of unchanged data is a cheap no-op PUT.
|
|
12
|
-
*
|
|
13
|
-
* The returned URL is the plan entry's `serve_url`, read verbatim. See the note at
|
|
14
|
-
* the return for why the origin-relative form is safe to store.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { createHash } from 'node:crypto'
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* @param {object} client - BackendClient (origin + uploadSiteAssets)
|
|
21
|
-
* @param {{ data: object, search: object }} ball - the assembled data ball
|
|
22
|
-
* @param {{ onProgress?: (m: string) => void }} [opts]
|
|
23
|
-
* @returns {Promise<string>} the content-addressed serve URL (→ `info.data_bundle`)
|
|
24
|
-
*/
|
|
25
|
-
export async function uploadDataBundle(
|
|
26
|
-
client,
|
|
27
|
-
ball,
|
|
28
|
-
{ siteUuid = null, onProgress } = {}
|
|
29
|
-
) {
|
|
30
|
-
const bytes = Buffer.from(JSON.stringify(ball))
|
|
31
|
-
const sha256 = createHash('sha256').update(bytes).digest('hex')
|
|
32
|
-
const localUrl = '/data-bundle/base.json' // bookkeeping key into assetsByLocalUrl
|
|
33
|
-
|
|
34
|
-
const result = await client.uploadSiteAssets({
|
|
35
|
-
files: [
|
|
36
|
-
{
|
|
37
|
-
path: 'data-bundle/base.json',
|
|
38
|
-
content_type: 'application/json',
|
|
39
|
-
size: bytes.length,
|
|
40
|
-
sha256,
|
|
41
|
-
localUrl,
|
|
42
|
-
bytes
|
|
43
|
-
}
|
|
44
|
-
],
|
|
45
|
-
siteUuid,
|
|
46
|
-
onProgress
|
|
47
|
-
})
|
|
48
|
-
if (result.failed?.length) {
|
|
49
|
-
const f = result.failed[0]
|
|
50
|
-
throw new Error(`data-bundle upload failed: HTTP ${f.status} ${f.detail}`)
|
|
51
|
-
}
|
|
52
|
-
const entry = result.assetsByLocalUrl[localUrl]
|
|
53
|
-
if (!entry) throw new Error('data-bundle upload returned no asset id')
|
|
54
|
-
|
|
55
|
-
// The backend's canonical serve URL, READ — never composed. Same rule and the
|
|
56
|
-
// same code path as `site-media.js`; until 2026-08-17 this composed from a
|
|
57
|
-
// discovered `assetBase` unconditionally, which made it the one place a
|
|
58
|
-
// discovery failure could bake the historical production CDN host into content
|
|
59
|
-
// we push.
|
|
60
|
-
//
|
|
61
|
-
// Storing it verbatim is safe even though `serve_url` is ORIGIN-RELATIVE in the
|
|
62
|
-
// backend's `direct` mode — the only mode any deployment has ever run.
|
|
63
|
-
// `info.data_bundle` is never fetched over HTTP: the backend resolves it to a
|
|
64
|
-
// blob-store key, discarding everything before the final `dist/`, so absolute
|
|
65
|
-
// and relative forms resolve to the same key (pinned both ways on their side).
|
|
66
|
-
//
|
|
67
|
-
// ⚠️ That argument is scoped to a serve URL CONTAINING `dist/`, and the scope is
|
|
68
|
-
// load-bearing rather than incidental: the key is recovered by splitting on that
|
|
69
|
-
// segment, so a serve URL without one cannot be resolved to a key at all — the
|
|
70
|
-
// failure is on the READING side, and this push looks entirely successful.
|
|
71
|
-
// Reported by the backend 2026-08-17 as a defect on their side, not yet fixed;
|
|
72
|
-
// not verified here, and not ours to fix. It is unreached only because no
|
|
73
|
-
// deployment yet mints URLs of the other shape. ⇒ We keep reading `serve_url`
|
|
74
|
-
// verbatim — composing one would be the worse answer, and it is the very coupling
|
|
75
|
-
// deleted above. What we must NOT do is infer from "this has always worked" that
|
|
76
|
-
// any serve URL round-trips; that holds for the shape, not for the field.
|
|
77
|
-
//
|
|
78
|
-
// Absent is an error, not a cue to invent a location: an unaddressable bundle
|
|
79
|
-
// must stop the publish, never ship a URL nobody claimed. (Confirmed with the
|
|
80
|
-
// backend 2026-08-17; `serve_url` is contractually on every plan entry.)
|
|
81
|
-
if (!entry.serveUrl)
|
|
82
|
-
throw new Error('data-bundle upload returned no serve_url')
|
|
83
|
-
|
|
84
|
-
return entry.serveUrl
|
|
85
|
-
}
|