uniweb 0.12.32 → 0.12.34

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.
@@ -32,18 +32,20 @@
32
32
  * Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
33
33
  *
34
34
  * A project that never pushed has no `$uuid` to pull by — pull is a no-op with a
35
- * clear message. NOTE: the backend pull routes have not been exercised live; the
36
- * response-envelope extraction (extractDocument / splitCollectionsPull) is
37
- * deliberately tolerant and is the single point to adjust at the first live run.
35
+ * clear message. The backend serves each lane as a `.uwx` (ZIP: `manifest.json` +
36
+ * `entities/<uuid>.json`); `readPullDocuments` reads the entity files out of it, with
37
+ * a tolerant JSON fallback (`extractDocument` / `splitCollectionsPull`). Verified live
38
+ * against the playground backend, 2026-06-17.
38
39
  */
39
40
 
40
- import { readFileSync } from 'node:fs'
41
- import { join } from 'node:path'
41
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
42
+ import { join, dirname } from 'node:path'
42
43
  import yaml from 'js-yaml'
43
44
  import {
44
45
  siteContentDocumentToProject,
45
46
  collectionsToProject,
46
47
  resolveCollectionsConfig,
48
+ readZip,
47
49
  } from '@uniweb/build/uwx'
48
50
  import { makeModelResolver } from './push.js'
49
51
  import { BackendClient } from '../backend/client.js'
@@ -76,6 +78,27 @@ function readYamlUuid(filePath) {
76
78
  }
77
79
  }
78
80
 
81
+ // Conditional-pull ETag cache (gitignored `.uniweb/pull-cache.json`): the last ETag
82
+ // seen per lane. The ETag is OPAQUE — cached and echoed verbatim in If-None-Match,
83
+ // never parsed or recomputed (the backend owns the hash; the client treats it as a
84
+ // token). A missing cache just means a full (unconditional) pull.
85
+ function pullCachePath(siteDir) {
86
+ return join(siteDir, '.uniweb', 'pull-cache.json')
87
+ }
88
+ function readPullCache(siteDir) {
89
+ try {
90
+ const obj = JSON.parse(readFileSync(pullCachePath(siteDir), 'utf8'))
91
+ return obj && typeof obj === 'object' ? obj : {}
92
+ } catch {
93
+ return {}
94
+ }
95
+ }
96
+ function writePullCache(siteDir, { content, folder }) {
97
+ const p = pullCachePath(siteDir)
98
+ mkdirSync(dirname(p), { recursive: true })
99
+ writeFileSync(p, JSON.stringify({ version: 1, content, folder }, null, 2) + '\n')
100
+ }
101
+
79
102
  // Extract a single entity `$`-document from a pull response. Tolerant of a raw
80
103
  // document, or a `{ document }` / `{ entity }` envelope. (Adjust at live e2e.)
81
104
  export function extractDocument(payload) {
@@ -104,6 +127,46 @@ export function splitCollectionsPull(payload) {
104
127
  }
105
128
  }
106
129
 
130
+ // Read a pull lane's bytes into entity `$`-documents. The backend serves a `.uwx`
131
+ // (our Stored ZIP: `manifest.json` + `entities/<uuid>.json`); the entity files ARE the
132
+ // documents. Falls back to a JSON body (a raw doc, a `{document}`/`{entity}` envelope,
133
+ // or a list) so the lane survives a future envelope change. Returns an array (possibly
134
+ // empty).
135
+ export function readPullDocuments(buf) {
136
+ // `.uwx` ZIP — the local-file signature is "PK\x03\x04".
137
+ if (buf.length >= 2 && buf[0] === 0x50 && buf[1] === 0x4b) {
138
+ const docs = []
139
+ for (const [name, data] of readZip(buf)) {
140
+ if (name === 'manifest.json' || !name.endsWith('.json')) continue
141
+ try {
142
+ docs.push(JSON.parse(data.toString('utf8')))
143
+ } catch {
144
+ /* skip a non-document entry */
145
+ }
146
+ }
147
+ return docs
148
+ }
149
+ // JSON fallback — flatten any envelope splitCollectionsPull understands into a
150
+ // flat `$`-document list (a raw doc, a list, `{entities}`/`{documents}`, or
151
+ // `{folder, records}`).
152
+ let payload
153
+ try {
154
+ payload = JSON.parse(buf.toString('utf8'))
155
+ } catch {
156
+ return []
157
+ }
158
+ if (Array.isArray(payload)) return payload.map(extractDocument).filter(Boolean)
159
+ if (payload?.folder) return [payload.folder, ...(payload.records || [])].filter(Boolean)
160
+ const list = Array.isArray(payload?.entities)
161
+ ? payload.entities
162
+ : Array.isArray(payload?.documents)
163
+ ? payload.documents
164
+ : null
165
+ if (list) return list.map(extractDocument).filter(Boolean)
166
+ const single = extractDocument(payload)
167
+ return single ? [single] : []
168
+ }
169
+
107
170
  /**
108
171
  * @param {string[]} args
109
172
  * @param {object} [deps] - injectable seams for testing: `fetch` (default global
@@ -141,10 +204,12 @@ export async function pull(args = [], deps = {}) {
141
204
  return { exitCode: 0 }
142
205
  }
143
206
 
144
- // GET a pull lane via the client and parse it as JSON. `doRequest` is a thunk
145
- // returning the client's Response promise. 404 null (deleted / no access); any
146
- // failure is reported and returns null (the lane is skipped, not fatal).
147
- const getJson = async (label, doRequest) => {
207
+ // GET a pull lane via the client and return `{ docs, etag }` from its `.uwx` (ZIP)
208
+ // body `readPullDocuments` reads the entity files out of it (JSON fallback). A
209
+ // conditional request whose ETag matches returns `{ notModified: true }` (304, empty
210
+ // body). `doRequest` is a thunk returning the client's Response promise. 404 / any
211
+ // failure → null (the lane is skipped, not fatal).
212
+ const getDocs = async (label, doRequest) => {
148
213
  info(`Pulling ${colors.bright}${label}${colors.reset} from ${colors.dim}${client.origin}${colors.reset} …`)
149
214
  let res
150
215
  try {
@@ -158,15 +223,21 @@ export async function pull(args = [], deps = {}) {
158
223
  note(`${label}: not found (404) — it was deleted, or you lack access.`)
159
224
  return null
160
225
  }
226
+ if (res.status === 304) {
227
+ note(`${label}: unchanged (304)`)
228
+ return { notModified: true }
229
+ }
161
230
  if (!res.ok) {
162
231
  error(`${label} pull failed: HTTP ${res.status} ${res.statusText}`)
163
232
  if (res.status === 401 || res.status === 403) note("Credentials weren't accepted — supply a bearer with --token <bearer>.")
164
233
  return null
165
234
  }
166
235
  try {
167
- return await res.json()
236
+ const etag = res.headers?.get?.('etag') ?? null
237
+ const docs = readPullDocuments(Buffer.from(await res.arrayBuffer()))
238
+ return { docs, etag }
168
239
  } catch (err) {
169
- error(`Could not parse the ${label} response as JSON: ${err.message}`)
240
+ error(`Could not read the ${label} response: ${err.message}`)
170
241
  return null
171
242
  }
172
243
  }
@@ -176,25 +247,34 @@ export async function pull(args = [], deps = {}) {
176
247
  let records = 0
177
248
  let deleted = 0
178
249
 
179
- // Lane 1 content config + pages/** + layout/**.
180
- const siteDoc = extractDocument(
181
- await getJson('content', () => client.pullSiteContent(siteContentUuid))
182
- )
183
- if (siteDoc) {
184
- const report = siteContentDocumentToProject({ document: siteDoc, siteRoot: siteDir, prune })
185
- pages += report.pages.length
186
- sections += report.sections.length
187
- deleted += report.deleted.length
250
+ // Conditional-pull cache: the last ETag seen per lane (opaque token — cached and
251
+ // echoed verbatim, never recomputed). Lives in the gitignored `.uniweb/`.
252
+ const cache = readPullCache(siteDir)
253
+ let etagContent = cache.content
254
+ let etagFolder = cache.folder
255
+
256
+ // Lane 1 — content → config + pages/** + layout/**. The .uwx carries a single
257
+ // entity (the site-content document). A 304 (unchanged) leaves local files as-is.
258
+ const content = await getDocs('content', () => client.pullSiteContent(siteContentUuid, { etag: etagContent }))
259
+ if (content && !content.notModified) {
260
+ const siteDoc = content.docs && (content.docs.find((d) => d?.info || d?.$model) || content.docs[0] || null)
261
+ if (siteDoc) {
262
+ const report = siteContentDocumentToProject({ document: siteDoc, siteRoot: siteDir, prune })
263
+ pages += report.pages.length
264
+ sections += report.sections.length
265
+ deleted += report.deleted.length
266
+ }
267
+ if (content.etag) etagContent = content.etag
188
268
  }
189
269
 
190
270
  // Lane 2 — folder → the folder + record files, keyed by the SAME site-content uuid
191
271
  // (the backend resolves the site's `@uniweb/folder` from it; the framework never
192
272
  // holds a folder uuid). Models are resolved by name (async) up front, so
193
- // collectionsToProject keeps its synchronous contract.
273
+ // collectionsToProject keeps its synchronous contract. A 304 leaves files as-is.
194
274
  if (!noCollections) {
195
- const payload = await getJson('collections', () => client.pullFolder(siteContentUuid))
196
- if (payload) {
197
- const { folderDoc, recordDocs } = splitCollectionsPull(payload)
275
+ const folder = await getDocs('collections', () => client.pullFolder(siteContentUuid, { etag: etagFolder }))
276
+ if (folder && !folder.notModified && folder.docs?.length) {
277
+ const { folderDoc, recordDocs } = splitCollectionsPull(folder.docs)
198
278
  const resolveModel = makeModelResolver({ client })
199
279
  const declByModel = new Map()
200
280
  for (const model of [...new Set(recordDocs.map((d) => d.$model).filter(Boolean))]) {
@@ -215,8 +295,12 @@ export async function pull(args = [], deps = {}) {
215
295
  for (const s of report.skipped) note(`↷ ${s.slug ?? s.uuid ?? '(record)'}: ${s.reason}`)
216
296
  for (const w of report.warnings) note(`! ${w}`)
217
297
  }
298
+ if (folder?.etag) etagFolder = folder.etag
218
299
  }
219
300
 
301
+ // Persist the ETags so the next pull is conditional (304 when unchanged).
302
+ writePullCache(siteDir, { content: etagContent, folder: etagFolder })
303
+
220
304
  success(
221
305
  `Pulled — ${pages} page(s), ${sections} section(s), ${records} record(s)` + (deleted ? `, ${deleted} deleted` : '')
222
306
  )
@@ -34,17 +34,23 @@
34
34
  * Backend: via BackendClient (the content + folder sync lanes). Origin from
35
35
  * --registry > UNIWEB_REGISTER_URL > the local default.
36
36
  * Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
37
+ *
38
+ * The two-lane SUBMISSION (POST both lanes, back-fill uuids, persist the
39
+ * send-only-changed cache) lives in `../backend/site-sync.js` so `uniweb deploy`
40
+ * (the composite path) reuses the exact same logic. This command owns flag parsing,
41
+ * the emit, and the `-o`/`--dry-run` preview.
37
42
  */
38
43
 
39
- import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'
40
- import { resolve, join, dirname } from 'node:path'
41
- import {
42
- emitSyncPackages,
43
- backfillEntityUuids,
44
- writeSiteEntityUuid,
45
- } from '@uniweb/build/uwx'
44
+ import { writeFileSync } from 'node:fs'
45
+ import { resolve } from 'node:path'
46
+ import { emitSyncPackages } from '@uniweb/build/uwx'
46
47
  import { BackendClient } from '../backend/client.js'
47
48
  import { resolveSiteDir } from './deploy.js'
49
+ import { makeModelResolver, readSyncCache, pushSyncPackages } from '../backend/site-sync.js'
50
+
51
+ // Re-exported for downstream importers (pull.js, push.test.js) that read these
52
+ // helpers from this module — their canonical home is now ../backend/site-sync.js.
53
+ export { extractMintedSiteUuid, makeModelResolver } from '../backend/site-sync.js'
48
54
 
49
55
  const colors = {
50
56
  reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m',
@@ -64,101 +70,6 @@ function flagValue(args, name) {
64
70
  return null
65
71
  }
66
72
 
67
- // Pull the finalized entities out of the restore response. The backend returns
68
- // `{ report: { finalized: [ { index, uuid, changed, document }, … ] } }` — each
69
- // entry carries its position in the SUBMITTED sequence (`index`, the correlation
70
- // key — `$id` is not echoed), the minted entity `uuid`, a `changed` flag, and the
71
- // full `document` (verbatim stored content with every `$uuid` filled in). A couple
72
- // of shapes are tolerated; only entries with a valid `index` + `uuid` are usable.
73
- function extractFinalized(payload) {
74
- const list = Array.isArray(payload?.report?.finalized)
75
- ? payload.report.finalized
76
- : Array.isArray(payload?.finalized)
77
- ? payload.finalized
78
- : Array.isArray(payload)
79
- ? payload
80
- : null
81
- if (!list) return null
82
- return list
83
- .map((d) => ({
84
- index: d?.index,
85
- uuid: d?.uuid ?? d?.document?.$uuid ?? null,
86
- changed: d?.changed,
87
- document: d?.document ?? null,
88
- }))
89
- .filter((e) => Number.isInteger(e.index) && e.uuid)
90
- }
91
-
92
- // Pull the minted site-content uuid out of a CREATE
93
- // response. The exact shape is an open backend item — tolerant of a bare
94
- // `{ siteContentUuid }` / `{ $uuid }` / `{ uuid }`, or the same `report.finalized[]`
95
- // envelope the update/folder lanes return (the site entity is submitted alone, so its
96
- // minted uuid is the first finalized entry). Returns null if none is present. (Single
97
- // adjust-point to pin at the first live CREATE.)
98
- export function extractMintedSiteUuid(payload) {
99
- if (typeof payload?.siteContentUuid === 'string') return payload.siteContentUuid
100
- if (typeof payload?.$uuid === 'string') return payload.$uuid
101
- if (typeof payload?.uuid === 'string') return payload.uuid
102
- const finalized = extractFinalized(payload)
103
- if (finalized && finalized.length) {
104
- const site = finalized.find((f) => f.index === 0) || finalized[0]
105
- return site?.uuid ?? null
106
- }
107
- return null
108
- }
109
-
110
- // One-line summary from the authoritative per-entity `changed` flag (`false` = a
111
- // true no-op). Falls back silently when the backend omits it.
112
- function changedSummary(finalized) {
113
- const changed = finalized.filter((f) => f.changed === true).length
114
- const unchanged = finalized.filter((f) => f.changed === false).length
115
- const parts = []
116
- if (changed) parts.push(`${changed} changed`)
117
- if (unchanged) parts.push(`${unchanged} unchanged`)
118
- return parts.length ? parts.join(', ') : null
119
- }
120
-
121
- // Resolve a Model NOT defined by the local foundation by reading its declaration
122
- // (the `@uniweb/data-schema` form) from the backend via the client. Cached per run;
123
- // HTTP 404 → null (the emitter then says "register it first"). The bearer is acquired
124
- // lazily by the client, so a fully-local sync never authenticates.
125
- //
126
- // `offline` (set for `-o` / `--dry-run`) forces every non-local Model to null WITHOUT
127
- // touching the backend — an offline emit must never authenticate. The collections
128
- // emitter then soft-skips a convention-defaulted schema with a warning ("not synced")
129
- // and still emits the site-content lane; an EXPLICIT non-local schema surfaces as a
130
- // clear "could not be resolved" error rather than an auth prompt.
131
- export function makeModelResolver({ client, offline = false }) {
132
- const cache = new Map()
133
- return async (modelName) => {
134
- if (cache.has(modelName)) return cache.get(modelName)
135
- const decl = offline ? null : await client.readDataSchema(modelName)
136
- cache.set(modelName, decl)
137
- return decl
138
- }
139
- }
140
-
141
- // "Send only changed" cache: content hashes from the last successful sync, keyed
142
- // `<model> <id>`. Gitignored, per-clone, deletable (a deleted cache just means one
143
- // full re-sync, which the backend then no-ops). NOT identity — the minted `$uuid`
144
- // lives in the source files; this is a pure wire-efficiency cache.
145
- function syncCachePath(siteDir) {
146
- return join(siteDir, '.uniweb', 'sync-cache.json')
147
- }
148
- function readSyncCache(siteDir) {
149
- try {
150
- const obj = JSON.parse(readFileSync(syncCachePath(siteDir), 'utf8'))
151
- return obj && typeof obj.hashes === 'object' && obj.hashes ? obj.hashes : {}
152
- } catch {
153
- return {} // missing / unreadable → treat everything as changed
154
- }
155
- }
156
- function writeSyncCache(siteDir, hashes) {
157
- const p = syncCachePath(siteDir)
158
- mkdirSync(dirname(p), { recursive: true })
159
- writeFileSync(p, JSON.stringify({ version: 1, hashes }, null, 2) + '\n')
160
- }
161
-
162
73
  export async function push(args = []) {
163
74
  const dryRun = args.includes('--dry-run')
164
75
  const output = flagValue(args, '-o') || flagValue(args, '--output')
@@ -197,7 +108,7 @@ export async function push(args = []) {
197
108
  error(`Could not build the sync package: ${err.message}`)
198
109
  return { exitCode: 2 }
199
110
  }
200
- const { siteContent, collections, siteContentUuid, warnings, hashes, skipped } = pkg
111
+ const { siteContent, collections, siteContentUuid, warnings, skipped } = pkg
201
112
  log('')
202
113
  for (const w of warnings) note(`! ${w}`)
203
114
 
@@ -235,116 +146,19 @@ export async function push(args = []) {
235
146
  return { exitCode: 0 }
236
147
  }
237
148
 
238
- const wrote = []
239
- let finalizedTotal = 0
240
-
241
- // POST one lane via the client and parse the JSON response. `doRequest` is a thunk
242
- // returning the client's Response promise (so the "Pushing …" line prints before the
243
- // request fires). The client carries `collision=force` (last-push-wins) + the optional
244
- // `--as-org`. Returns the parsed payload, or null on any transport/HTTP/parse failure
245
- // (already reported).
246
- const postLane = async (label, doRequest) => {
247
- info(`Pushing ${label} to ${colors.dim}${client.origin}${colors.reset} …`)
248
- let res
249
- try {
250
- res = await doRequest()
251
- } catch (err) {
252
- error(`Could not reach the backend at ${client.origin}: ${err.message}`)
253
- note('Set the origin with --registry <url> or UNIWEB_REGISTER_URL.')
254
- return null
255
- }
256
- if (!res.ok) {
257
- error(`${label} push rejected: HTTP ${res.status} ${res.statusText}`)
258
- if (res.status === 401 || res.status === 403) {
259
- note("Credentials weren't accepted — supply a bearer with --token <bearer> (or UNIWEB_TOKEN).")
260
- }
261
- const body = await res.text().catch(() => '')
262
- if (body) note(body.slice(0, 800))
263
- return null
264
- }
265
- try {
266
- return await res.json()
267
- } catch (err) {
268
- error(`Could not parse the ${label} response as JSON: ${err.message}`)
269
- return null
270
- }
271
- }
272
-
273
- // POST a lane that round-trips entity uuids (content UPDATE + the folder): parse the
274
- // finalized list (for record back-fill + the changed summary). Returns the finalized
275
- // array, or null on failure (already reported).
276
- const pushLane = async (label, doRequest) => {
277
- const payload = await postLane(label, doRequest)
278
- if (payload === null) return null
279
- const finalized = extractFinalized(payload)
280
- if (!finalized) {
281
- error(`The ${label} response carried no recognizable finalized list (expected report.finalized[] with index + uuid).`)
282
- note(JSON.stringify(payload).slice(0, 800))
283
- return null
284
- }
285
- const summary = changedSummary(finalized)
286
- if (summary) note(`${label}: ${summary}`)
287
- return finalized
288
- }
289
-
290
- // Lane 1 — site-content (the site is born here; it must exist before its folder). A
291
- // known site uuid → UPDATE by uuid; none → CREATE (the backend mints + adopts the
292
- // site and returns its uuid, which we record into site.yml). `boundSiteUuid` carries
293
- // the minted/known uuid forward to key the folder push.
294
- let boundSiteUuid = siteContentUuid
295
- if (siteContent) {
296
- if (siteContentUuid) {
297
- const finalized = await pushLane(
298
- 'site-content',
299
- () => client.updateSiteContent(siteContentUuid, siteContent.buffer, { asOrg })
300
- )
301
- if (!finalized) return { exitCode: 1 }
302
- finalizedTotal += finalized.length
303
- } else {
304
- const payload = await postLane(
305
- 'site-content',
306
- () => client.createSiteContent(siteContent.buffer, { asOrg })
307
- )
308
- if (payload === null) return { exitCode: 1 }
309
- const minted = extractMintedSiteUuid(payload)
310
- if (!minted) {
311
- error('The create response carried no minted site-content uuid — cannot record the site identity or push its folder.')
312
- note(JSON.stringify(payload).slice(0, 800))
313
- return { exitCode: 1 }
314
- }
315
- writeSiteEntityUuid(siteDir, minted)
316
- boundSiteUuid = minted
317
- wrote.push('recorded site $uuid in site.yml')
318
- finalizedTotal += extractFinalized(payload)?.length ?? 1
319
- }
320
- }
321
-
322
- // Lane 2 — collections (the @uniweb/folder + the records it references), keyed by the
323
- // site-content uuid. On a brand-new site the backend creates the folder on this first
324
- // push. Records round-trip their own $uuid (back-filled into source files); the folder
325
- // itself has no uuid (the backend owns it).
326
- if (collections) {
327
- if (!boundSiteUuid) {
328
- error('Cannot push collections — the site has no uuid yet. Push the site-content lane first.')
329
- return { exitCode: 1 }
330
- }
331
- const finalized = await pushLane(
332
- 'collections',
333
- () => client.pushFolder(boundSiteUuid, collections.buffer, { asOrg })
334
- )
335
- if (!finalized) return { exitCode: 1 }
336
- const bf = backfillEntityUuids({ index: collections.index, finalized })
337
- for (const w of bf.warnings) note(`! ${w}`)
338
- for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
339
- if (bf.updated.length) wrote.push(`wrote ${bf.updated.length} record file(s)`)
340
- finalizedTotal += finalized.length
341
- }
342
-
343
- // Persist the full content-hash map so the next push skips unchanged entities.
344
- writeSyncCache(siteDir, hashes)
149
+ // Submit both lanes, back-fill the minted uuids, and persist the send-only-changed
150
+ // cache. Shared with `uniweb deploy` via ../backend/site-sync.js.
151
+ const result = await pushSyncPackages({
152
+ client,
153
+ siteDir,
154
+ pkg,
155
+ asOrg,
156
+ report: { info, note, error, dim: (s) => `${colors.dim}${s}${colors.reset}` },
157
+ })
158
+ if (result.exitCode !== 0) return { exitCode: result.exitCode }
345
159
  success(
346
- `Pushed ${finalizedTotal} entit${finalizedTotal === 1 ? 'y' : 'ies'}` +
347
- (wrote.length ? ` — ${wrote.join(', ')}` : '')
160
+ `Pushed ${result.finalizedTotal} entit${result.finalizedTotal === 1 ? 'y' : 'ies'}` +
161
+ (result.wrote.length ? ` — ${result.wrote.join(', ')}` : '')
348
162
  )
349
163
  return { exitCode: 0 }
350
164
  }
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-06-17T20:00:18.933Z",
3
+ "generatedAt": "2026-06-23T17:13:47.118Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.14.15",
6
+ "version": "0.14.17",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -61,7 +61,7 @@
61
61
  "deps": []
62
62
  },
63
63
  "@uniweb/runtime": {
64
- "version": "0.8.19",
64
+ "version": "0.8.20",
65
65
  "path": "framework/runtime",
66
66
  "deps": [
67
67
  "@uniweb/core",
@@ -99,7 +99,7 @@
99
99
  "deps": []
100
100
  },
101
101
  "@uniweb/unipress": {
102
- "version": "0.4.21",
102
+ "version": "0.4.23",
103
103
  "path": "framework/unipress",
104
104
  "deps": [
105
105
  "@uniweb/build",
@@ -73,10 +73,15 @@ export function collectSiteAssets(distDir) {
73
73
 
74
74
  /**
75
75
  * Deliver a site's assets: plan, then PUT each NEW file. Returns the rewrite map
76
- * (`localUrl → { id, ext }`) — populated for files that uploaded successfully OR
77
- * that the plan reports already `present`, so a partial failure never injects a
78
- * broken serve URL into content. Throws only on a plan-level failure; per-file
79
- * PUT failures surface in `failed`.
76
+ * (`localUrl → { id, ext, serveUrl }`) — populated for files that uploaded
77
+ * successfully OR that the plan reports already `present`, so a partial failure
78
+ * never injects a broken serve URL into content. Throws only on a plan-level
79
+ * failure; per-file PUT failures surface in `failed`.
80
+ *
81
+ * `serveUrl` is the backend's canonical, ready-built serve URL for the asset (the
82
+ * plan entry's `serve_url`; content-addressed, lane-independent). Callers embed it
83
+ * verbatim; `buildAssetUrl(origin, assetBase, id, ext)` reconstructs the same
84
+ * string and stays as a fallback for an older backend that omits `serve_url`.
80
85
  *
81
86
  * Skip-list (content-addressed dedup): a plan entry with `present: true` is
82
87
  * already in the global store — we don't re-PUT it, but we DO record its id+ext
@@ -89,7 +94,7 @@ export function collectSiteAssets(distDir) {
89
94
  * @param {string} opts.distDir - the site's built dist/ directory
90
95
  * @param {Array} [opts.files] - pre-collected list (default: collectSiteAssets)
91
96
  * @param {(msg: string) => void} [opts.onProgress]
92
- * @returns {Promise<{ mode: string, uploaded: string[], skipped: string[], failed: Array<{path, status, detail}>, assetsByLocalUrl: Record<string, { id: string, ext: string }> }>}
97
+ * @returns {Promise<{ mode: string, uploaded: string[], skipped: string[], failed: Array<{path, status, detail}>, assetsByLocalUrl: Record<string, { id: string, ext: string, serveUrl?: string }> }>}
93
98
  */
94
99
  export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgress = () => {} }) {
95
100
  const list = files || collectSiteAssets(distDir)
@@ -130,7 +135,7 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
130
135
  // ⇒ false (older backend) → falls through to the upload path below.
131
136
  if (up.present) {
132
137
  skipped.push(src.path)
133
- assetsByLocalUrl[src.localUrl] = { id: up.id, ext: String(up.ext || '').replace(/^\./, '') }
138
+ assetsByLocalUrl[src.localUrl] = { id: up.id, ext: String(up.ext || '').replace(/^\./, ''), serveUrl: up.serve_url }
134
139
  continue
135
140
  }
136
141
 
@@ -144,7 +149,7 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
144
149
  try {
145
150
  // The plan's url may be origin-relative (direct mode → uniwebd) or
146
151
  // absolute (presigned → storage); new URL() resolves both.
147
- putRes = await fetch(new URL(up.url, origin), { method: up.method || 'PUT', headers, body: readFileSync(src.diskPath) })
152
+ putRes = await fetch(new URL(up.url, origin), { method: up.method || 'PUT', headers, body: src.bytes ?? readFileSync(src.diskPath) })
148
153
  } catch (err) {
149
154
  failed.push({ path: src.path, status: 0, detail: err.message })
150
155
  continue
@@ -152,7 +157,7 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
152
157
  if (putRes.ok) {
153
158
  uploaded.push(src.path)
154
159
  // Authoritative id + ext from the plan; mapped only on a successful PUT.
155
- assetsByLocalUrl[src.localUrl] = { id: up.id, ext: String(up.ext || '').replace(/^\./, '') }
160
+ assetsByLocalUrl[src.localUrl] = { id: up.id, ext: String(up.ext || '').replace(/^\./, ''), serveUrl: up.serve_url }
156
161
  } else {
157
162
  failed.push({ path: src.path, status: putRes.status, detail: await putRes.text().catch(() => '') })
158
163
  }
@@ -160,3 +165,12 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
160
165
 
161
166
  return { mode, uploaded, skipped, failed, assetsByLocalUrl }
162
167
  }
168
+
169
+ // Build a durable asset serve URL from /dev/config's assetBase. Origin-relative
170
+ // (`/gateway/asset/` in dev) → prepend the backend origin; absolute (a prod CDN)
171
+ // → used verbatim. Shape: {assetBase}dist/{id}/base.{ext} — basename literally
172
+ // `base`, {ext} the source extension the plan echoed.
173
+ export function buildAssetUrl(origin, assetBase, id, ext) {
174
+ const base = /^https?:\/\//.test(assetBase) ? assetBase : `${origin}${assetBase}`
175
+ return `${base.replace(/\/$/, '')}/dist/${id}/base.${ext}`
176
+ }