uniweb 0.16.3 → 0.16.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.16.3",
3
+ "version": "0.16.5",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,14 +41,14 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "0.8.3",
45
- "@uniweb/runtime": "0.11.3",
46
- "@uniweb/kit": "0.11.1"
44
+ "@uniweb/core": "^0.8.5",
45
+ "@uniweb/kit": "^0.11.3",
46
+ "@uniweb/runtime": "^0.11.5"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/build": "0.18.3",
50
- "@uniweb/semantic-parser": "1.2.1",
51
- "@uniweb/content-reader": "1.2.2"
49
+ "@uniweb/build": "^0.18.5",
50
+ "@uniweb/content-reader": "^1.2.2",
51
+ "@uniweb/semantic-parser": "^1.2.2"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -1986,13 +1986,21 @@ Platform-specific configuration that doesn't belong in npm-standard fields. All
1986
1986
  | `namespace` | `uniweb register` | none | Legacy explicit org-namespace override; equivalent to a scoped `package.json::name`. Rarely needed. |
1987
1987
  | `runtimePolicy` | `dist/runtime-pin.json` | unset | Declares how far past the recorded runtime version a host may move a site. |
1988
1988
 
1989
- **Runtime policy** (foundation authors only sites don't set this). At build time a foundation records the `@uniweb/runtime` version it linked against into `dist/runtime-pin.json`, and carries `uniweb.runtimePolicy` alongside it when you set one: `exact` (stay on the recorded version), `auto-patch` (within `MAJOR.MINOR.x`), `auto-minor` (within `MAJOR.x.y`). Leaving it unset is the normal case and the one to prefer the runtime is backwards-compatible at the minor level by convention. Set `exact` only if you depend on undocumented runtime internals or have audited against one release. Site owners cannot override your choice.
1989
+ **Runtime updates handled for you.** Your foundation's code links against the runtime: it externalizes `react`, `react-dom`, `react-dom/server`, both JSX runtimes and `@uniweb/core`, and the runtime supplies all of them at load time. So a build is bound to *that* React and *that* core API.
1990
1990
 
1991
- ⚠️ **The pin is a compatibility FLOOR, not a selector.** It records what your build binds to; it does not choose the runtime a site runs, and it cannot: a site loads your foundation **plus any extensions**, each emitting its own pin, while a site has exactly **one** runtime. Pins are plural, the choice is singular. The runtime a site runs is selected by `site.yml::runtime`.
1991
+ You don't declare anything for this. At build time your foundation records the `@uniweb/runtime` version it actually linked against (read from `node_modules`, not chosen) into `dist/runtime-pin.json`, and `uniweb register` sends it with your foundation, so the version you built against travels with it. **Whether two runtime versions are compatible is our determination, not a setting** you have no way to know whether we changed anything your code can reach, so we don't ask you to guess.
1992
1992
 
1993
- `uniweb register` reads the pin and sends it with your foundation, so the floor you built against travels with it. Its use is **validation**checking a site's chosen runtime against the floors of *every* foundation that site loads, which only something holding all of them can do. **That check is not implemented anywhere yet**, so today the floor is stated and carried but not enforced. Set `runtimePolicy` to declare your intent for when it is; don't design around it having an effect now.
1993
+ ⚠️ **Nothing acts on the recorded version yet** — today it is stated and carried, not enforced. Don't design around it having an effect.
1994
1994
 
1995
- **Why this is yours and not the site's.** A published site ships **no JS** — content, config, data and assets only. Your foundation is the only part that contains code linking against the runtime, and that link is wider than it looks: a foundation externalizes `react`, `react-dom`, `react-dom/server`, both JSX runtimes and `@uniweb/core`, all supplied by the runtime at load time. So a foundation built against a runtime is bound to *that* React and *that* core API. The general rule, which also explains why the reverse holds: **each policy is declared by whoever's code binds to the thing being updated** — you declare the runtime policy because your code links against the runtime; the site declares its foundation policy because its content binds to your section types.
1995
+ **The escape hatch,** for the one thing only you can know: if your foundation reaches into undocumented runtime internals, or you have audited it against exactly one runtime build and want no movement at all, set
1996
+
1997
+ ```json
1998
+ { "uniweb": { "runtimePolicy": "exact" } }
1999
+ ```
2000
+
2001
+ That freezes sites using your foundation on the recorded version. Only reach for it if one of those two things is actually true — a frozen foundation stops receiving runtime fixes, including security ones. Sites cannot override your choice.
2002
+
2003
+ > Older releases documented `auto-patch` and `auto-minor` here as well. They asked you to pick a compatibility rule out of our version numbers, which was never something you were in a position to evaluate — that determination is ours. Both values are still accepted so existing foundations keep working; neither is worth setting.
1996
2004
 
1997
2005
  `@uniweb/runtime` arrives **transitively** through `@uniweb/build`, so your foundation pins a runtime version without declaring one — that's intentional. **Don't add `@uniweb/runtime` to your foundation's dependencies**; to bump the pinned version, bump `@uniweb/build`. If `@uniweb/runtime` isn't resolvable at build time the pin is simply not emitted, and the build still succeeds — nothing in the toolchain requires it.
1998
2006
 
@@ -18,6 +18,7 @@ import { hasUncommittedContent } from '../utils/git.js'
18
18
  import {
19
19
  backfillEntityUuids,
20
20
  writeSiteEntityUuid,
21
+ writeSiteOrg,
21
22
  emitSyncPackages,
22
23
  readZip,
23
24
  diffSiteUnits,
@@ -390,6 +391,59 @@ export function writeItemUuids(siteDir, map) {
390
391
  updateSyncCache(siteDir, { itemUuids: map })
391
392
  }
392
393
 
394
+ /**
395
+ * The org this site was created under, as `@handle`, or null.
396
+ *
397
+ * Read back from `site.yml::$org` (stored bare — see `writeSiteOrg`) and re-dressed
398
+ * with the `@` the CLI and the wire both use. Callers pass it as `--as-org`'s default
399
+ * so an org named once, at create, does not have to be re-typed on every later push.
400
+ *
401
+ * Deliberately NOT a fallback for the flag: an explicit `--as-org` always wins and
402
+ * rides verbatim, so this can only add a value where the CLI previously sent none.
403
+ *
404
+ * @param {string} siteDir
405
+ * @returns {string|null}
406
+ */
407
+ export function readSiteOrg(siteDir) {
408
+ try {
409
+ const y = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))
410
+ const h = y && typeof y === 'object' ? y.$org : null
411
+ return typeof h === 'string' && h.trim()
412
+ ? `@${h.trim().replace(/^@/, '')}`
413
+ : null
414
+ } catch {
415
+ return null
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Record the org a just-minted site was created under, if one was named.
421
+ *
422
+ * Only what we were TOLD is recorded — when no `--as-org` was passed the backend
423
+ * chose the owner and its create response carries no org, so there is nothing to
424
+ * write and guessing one would be worse than the gap. Returns the display form for
425
+ * the caller's "here's what resolved" line, or null when nothing was recorded.
426
+ *
427
+ * @param {string} siteDir
428
+ * @param {string|null|undefined} asOrg - the `--as-org` value, `@handle` or bare
429
+ * @returns {string|null}
430
+ */
431
+ function recordSiteOrg(siteDir, asOrg) {
432
+ const handle = String(asOrg || '')
433
+ .replace(/^@/, '')
434
+ .replace(/\/.*$/, '')
435
+ .trim()
436
+ if (!handle) return null
437
+ try {
438
+ writeSiteOrg(siteDir, handle)
439
+ return `@${handle}`
440
+ } catch {
441
+ // The uuid is the load-bearing back-fill; losing the org note must never
442
+ // fail a push that already succeeded on the backend.
443
+ return null
444
+ }
445
+ }
446
+
393
447
  /**
394
448
  * Guarantee the site EXISTS on the backend before anything is uploaded against it.
395
449
  *
@@ -429,7 +483,7 @@ export async function ensureSiteExists({
429
483
  /* unreadable site.yml — treat as un-synced and let the create decide */
430
484
  }
431
485
  if (typeof siteYml.$uuid === 'string') {
432
- return { uuid: siteYml.$uuid, created: false }
486
+ return { uuid: siteYml.$uuid, created: false, org: readSiteOrg(siteDir) }
433
487
  }
434
488
 
435
489
  // Both are required by the create. Catching it here turns a 400 into a sentence
@@ -484,8 +538,16 @@ export async function ensureSiteExists({
484
538
  // this point cannot leave a cache pointing at a different site with no way to
485
539
  // detect it.
486
540
  updateSyncCache(siteDir, { siteUuid: minted })
487
- note?.(`Created the site on the backend (recorded $uuid in site.yml).`)
488
- return { uuid: minted, created: true }
541
+ // Ownership is decided HERE and nowhere else — this is the one call that reads
542
+ // `as_org`. Recording it beside the uuid is what makes the answer readable later,
543
+ // and naming it now is the "show what resolved" half of the ask-once pattern.
544
+ const org = recordSiteOrg(siteDir, asOrg)
545
+ note?.(
546
+ org
547
+ ? `Created the site on the backend under ${org} (recorded $uuid + $org in site.yml).`
548
+ : `Created the site on the backend (recorded $uuid in site.yml).`
549
+ )
550
+ return { uuid: minted, created: true, org }
489
551
  }
490
552
 
491
553
  /**
@@ -838,6 +900,12 @@ export async function pushSyncPackages({
838
900
  updateSyncCache(siteDir, { siteUuid: minted })
839
901
  boundSiteUuid = minted
840
902
  wrote.push('recorded site $uuid in site.yml')
903
+ // The OTHER create path (a media-less push never reaches `ensureSiteExists`,
904
+ // which is gated on the site having local media). Both mint a site, so both
905
+ // owe the same record — recording it in only one place would make `$org`
906
+ // present or absent depending on whether the site happens to have images.
907
+ const createdOrg = recordSiteOrg(siteDir, asOrg)
908
+ if (createdOrg) wrote.push(`recorded site $org (${createdOrg}) in site.yml`)
841
909
  const createdFinalized = extractFinalized(payload)
842
910
  harvest(createdFinalized)
843
911
  siteFinalizedDoc = createdFinalized?.[0]?.document || null
@@ -56,6 +56,7 @@ import { detectWorkspacePm, installCmd } from '../utils/pm.js'
56
56
  import { BackendClient } from '../backend/client.js'
57
57
  import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
58
58
  import { extractFoundationRef } from '../utils/site-content-refs.js'
59
+ import { readUwxDocuments } from '../utils/uwx-read.js'
59
60
 
60
61
  const colors = {
61
62
  reset: '\x1b[0m',
@@ -189,11 +190,19 @@ export async function clone(args = [], deps = {}) {
189
190
  command: 'Cloning'
190
191
  })
191
192
 
192
- // 1. GET the site-content document (no @uniweb/build needed for a read).
193
+ // 1. GET the site-content document.
194
+ //
195
+ // ⛔ The body is a `.uwx` ZIP, not JSON — `application/vnd.uniweb.exchange.
196
+ // entity+zip`, on `content` and `folder` pulls alike. This used to call
197
+ // `res.json()`, which failed on the ZIP magic and reported *"Could not reach
198
+ // the backend: Unexpected token 'P'"* — blaming the network for a body we had
199
+ // received intact and mis-read. Decode with the local reader: this command runs
200
+ // before a project exists, so `@uniweb/build/uwx`'s `readZip` is out of reach
201
+ // (see `utils/uwx-read.js`).
193
202
  info(
194
203
  `Reading site ${colors.bright}${siteUuid}${colors.reset} from ${colors.dim}${client.origin}${colors.reset} …`
195
204
  )
196
- let payload
205
+ let documents
197
206
  try {
198
207
  const res = await client.pullSiteContent(siteUuid)
199
208
  if (res.status === 404) {
@@ -206,13 +215,14 @@ export async function clone(args = [], deps = {}) {
206
215
  note('Run `uniweb login` first (or pass --token <bearer>).')
207
216
  return { exitCode: 1 }
208
217
  }
209
- payload = await res.json()
218
+ documents = readUwxDocuments(Buffer.from(await res.arrayBuffer()))
210
219
  } catch (err) {
211
220
  error(`Could not reach the backend at ${client.origin}: ${err.message}`)
212
221
  return { exitCode: 1 }
213
222
  }
214
223
 
215
- const document = extractDocument(payload)
224
+ const document =
225
+ documents.map(extractDocument).find(Boolean) || null
216
226
  if (!document) {
217
227
  error('The site-content response carried no recognizable document.')
218
228
  return { exitCode: 1 }
@@ -28,6 +28,12 @@
28
28
  * uniweb publish --dry-run Resolve everything; POST nothing
29
29
  * uniweb publish --yes Skip confirmations (CI); never block on a prompt
30
30
  * uniweb publish --force Overwrite upstream app-side edits (drop the push gate)
31
+ * uniweb publish --as-org @org Publish under @org (membership-gated). Only the
32
+ * FIRST publish of a site reads it — that create is
33
+ * what decides which org owns the site and whose
34
+ * storage its assets are charged to. It is then
35
+ * recorded as `site.yml::$org` and replayed, so it
36
+ * never has to be re-typed.
31
37
  * uniweb publish --no-save Skip the deploy.yml lastDeploy auto-save
32
38
  * uniweb publish --backend <url> Override the backend origin
33
39
  * uniweb publish --token <bearer> Auth bearer (skips `uniweb login`)
@@ -66,7 +72,8 @@ import {
66
72
  ensureItemUuids,
67
73
  ensureSiteExists,
68
74
  clearRemoteSyncStateIfUnbound,
69
- pushSyncPackages
75
+ pushSyncPackages,
76
+ readSiteOrg
70
77
  } from '../backend/site-sync.js'
71
78
  import { uploadDataBundle } from '../backend/data-bundle.js'
72
79
  import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
@@ -167,11 +174,16 @@ async function persistLastDeploy(siteDir, opts) {
167
174
  export async function publish(args = []) {
168
175
  const dryRun = args.includes('--dry-run')
169
176
  const noSave = args.includes('--no-save')
170
- const asOrg = readFlagValue(args, '--as-org')
171
177
  const foundationDir = readFlagValue(args, '--foundation') // optional local foundation for Model schemas
172
178
 
173
179
  const siteDir = await resolveSiteDir(args, 'publish')
174
180
 
181
+ // The acting org: the flag verbatim, else the one this site was CREATED under
182
+ // (`site.yml::$org`). Only the create reads `as_org`, so replaying the recorded
183
+ // handle reasserts existing ownership rather than choosing new ownership. Absent
184
+ // both, no `as_org` is sent — unchanged from before the record existed.
185
+ const asOrg = readFlagValue(args, '--as-org') || readSiteOrg(siteDir)
186
+
175
187
  // Advisory only — warns and ships. See utils/conformance.js for why this
176
188
  // is not a gate.
177
189
  await warnIfContentDoesNotConform(siteDir, { args })
@@ -210,11 +222,22 @@ export async function publish(args = []) {
210
222
  installed.length &&
211
223
  !installed.includes(siteYml.runtime)
212
224
  ) {
225
+ // ⚠️ Leads with REMOVING the pin, deliberately. A site ships no JS, so it
226
+ // has no basis for holding a runtime version, and `runtime:` is an
227
+ // operator-level override that is no longer part of the documented
228
+ // authoring surface. This message used to say "pin one of these in
229
+ // site.yml" — which pushed a reader deeper into a mechanism they should
230
+ // not be using, and named a key the docs no longer describe. Keep the
231
+ // installed list (it is the actionable part when a pin IS intended), but
232
+ // do not restore pin-first phrasing.
213
233
  say.err(
214
- `Runtime ${siteYml.runtime} (from site.yml) is not installed on the backend.`
234
+ `Runtime ${siteYml.runtime} (pinned in site.yml) is not installed on the backend.`
235
+ )
236
+ say.dim(
237
+ `Remove the \`runtime:\` pin and the backend chooses — a site ships no code, so it has no reason to hold one.`
215
238
  )
216
239
  say.dim(
217
- `Installed: ${installed.join(', ') || '(none)'} — pin one of these in site.yml (\`runtime:\`), or have it installed on the backend.`
240
+ `Installed: ${installed.join(', ') || '(none)'} — or pin one of those, or have ${siteYml.runtime} installed.`
218
241
  )
219
242
  if (!dryRun) return { exitCode: 1 }
220
243
  }
@@ -26,7 +26,11 @@
26
26
  *
27
27
  * Usage:
28
28
  * uniweb push Build, push both lanes, back-fill $uuid
29
- * uniweb push --as-org @org Act as @org (membership-gated)
29
+ * uniweb push --as-org @org Act as @org (membership-gated). Needed only
30
+ * for the FIRST push of a site — it decides
31
+ * which org owns it (and whose storage its
32
+ * assets are charged to). Recorded as
33
+ * `site.yml::$org` and replayed after that.
30
34
  * uniweb push --dry-run Report what would be pushed; submit nothing
31
35
  * uniweb push -o out.uwx Write the .uwx file(s) per lane; submit nothing
32
36
  * uniweb push --registry <url> Override the backend origin
@@ -68,7 +72,8 @@ import {
68
72
  ensureItemUuids,
69
73
  ensureSiteExists,
70
74
  clearRemoteSyncStateIfUnbound,
71
- pushSyncPackages
75
+ pushSyncPackages,
76
+ readSiteOrg
72
77
  } from '../backend/site-sync.js'
73
78
 
74
79
  // Re-exported for downstream importers (pull.js, push.test.js) that read these
@@ -106,7 +111,6 @@ export async function push(args = []) {
106
111
  const dryRun = args.includes('--dry-run')
107
112
  const output = flagValue(args, '-o') || flagValue(args, '--output')
108
113
  const tokenFlag = flagValue(args, '--token')
109
- const asOrg = flagValue(args, '--as-org')
110
114
  const foundationDir = flagValue(args, '--foundation')
111
115
  const sendAll = args.includes('--all') // bypass the send-only-changed cache
112
116
  // --force drops the optimistic-concurrency precondition, making the push
@@ -118,6 +122,13 @@ export async function push(args = []) {
118
122
 
119
123
  const siteDir = await resolveSiteDir(args, 'push')
120
124
 
125
+ // The acting org: the flag verbatim, else the one this site was CREATED under
126
+ // (`site.yml::$org`). The org is consumed by the create that mints `$uuid`, so
127
+ // a site that already exists is already owned — replaying the recorded handle
128
+ // reasserts that rather than choosing anything new. A site with no `$org`
129
+ // recorded sends no `as_org`, exactly as before.
130
+ const asOrg = flagValue(args, '--as-org') || readSiteOrg(siteDir)
131
+
121
132
  // Advisory only — warns and pushes. A malformed data block otherwise rides
122
133
  // the sync wire unchecked; see utils/conformance.js.
123
134
  await warnIfContentDoesNotConform(siteDir, { args })
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-10T22:41:12.349Z",
3
+ "generatedAt": "2026-08-12T22:01:32.128Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.18.3",
6
+ "version": "0.18.5",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -28,7 +28,7 @@
28
28
  "deps": []
29
29
  },
30
30
  "@uniweb/core": {
31
- "version": "0.8.3",
31
+ "version": "0.8.5",
32
32
  "path": "framework/core",
33
33
  "deps": [
34
34
  "@uniweb/semantic-parser",
@@ -46,7 +46,7 @@
46
46
  "deps": []
47
47
  },
48
48
  "@uniweb/kit": {
49
- "version": "0.11.1",
49
+ "version": "0.11.3",
50
50
  "path": "framework/kit",
51
51
  "deps": [
52
52
  "@uniweb/core",
@@ -65,7 +65,7 @@
65
65
  "deps": []
66
66
  },
67
67
  "@uniweb/projections": {
68
- "version": "0.2.6",
68
+ "version": "0.2.8",
69
69
  "path": "framework/projections",
70
70
  "deps": [
71
71
  "@uniweb/content-writer",
@@ -73,7 +73,7 @@
73
73
  ]
74
74
  },
75
75
  "@uniweb/runtime": {
76
- "version": "0.11.3",
76
+ "version": "0.11.5",
77
77
  "path": "framework/runtime",
78
78
  "deps": [
79
79
  "@uniweb/core",
@@ -96,7 +96,7 @@
96
96
  "deps": []
97
97
  },
98
98
  "@uniweb/semantic-parser": {
99
- "version": "1.2.1",
99
+ "version": "1.2.2",
100
100
  "path": "framework/semantic-parser",
101
101
  "deps": []
102
102
  },
@@ -111,7 +111,7 @@
111
111
  "deps": []
112
112
  },
113
113
  "@uniweb/unipress": {
114
- "version": "0.8.2",
114
+ "version": "0.8.4",
115
115
  "path": "framework/unipress",
116
116
  "deps": [
117
117
  "@uniweb/build",
package/src/index.js CHANGED
@@ -1318,6 +1318,10 @@ ${colors.bright}Options:${colors.reset}
1318
1318
  --yes Skip confirmations (CI); never block on a prompt
1319
1319
  --no-save Skip the deploy.yml lastDeploy auto-save
1320
1320
  --no-validate Skip the content-conformance check (it only warns)
1321
+ --as-org @org Publish under @org (membership-gated). Read only on a site's
1322
+ FIRST publish — that create decides which org owns the site,
1323
+ and whose storage its assets are charged to. Recorded as
1324
+ site.yml \$org and replayed, so it is never re-typed.
1321
1325
  --backend <url> Backend origin (default: \$UNIWEB_REGISTER_URL or built-in)
1322
1326
  --token <bearer> Auth bearer (skips \`uniweb login\`)
1323
1327
  `,
@@ -1693,6 +1697,7 @@ ${colors.bright}Global Options:${colors.reset}
1693
1697
  ${colors.bright}Publish Options:${colors.reset}
1694
1698
  --dry-run Resolve everything; release/sync/POST nothing
1695
1699
  --yes Skip confirmations (CI); never block on a prompt
1700
+ --as-org @org Publish under @org (first publish only; then remembered)
1696
1701
  --no-save Skip the deploy.yml lastDeploy auto-save
1697
1702
  --no-validate Skip the content-conformance check (it only warns)
1698
1703
  --backend <url> Backend origin (default: \$UNIWEB_REGISTER_URL or built-in)
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Minimal `.uwx` reader — for the commands that run BEFORE a project exists.
3
+ *
4
+ * ## Why this exists rather than importing the real one
5
+ *
6
+ * Every other `.uwx` reader in the CLI (`pull.js`, `site-sync.js`, `content.js`)
7
+ * imports `readZip` from `@uniweb/build/uwx`. That is correct for them: they run
8
+ * *inside* a project, where `@uniweb/build` resolves from the project's own
9
+ * `node_modules`.
10
+ *
11
+ * **`@uniweb/build` is not a dependency of this package** (see `package.json`),
12
+ * so a command that runs where no project exists cannot reach it — statically or
13
+ * dynamically. `uniweb clone` is exactly that command: its whole job is to turn a
14
+ * backend site into a local project that does not exist yet.
15
+ *
16
+ * So this is a deliberate second implementation, kept minimal and dependency-free
17
+ * (`node:zlib` is a builtin), the same way `clone.js` keeps its own
18
+ * `extractDocument` for the same reason. **Do not import this from a command that
19
+ * runs inside a project** — use `@uniweb/build/uwx`'s `readZip` there, so the
20
+ * producer and the consumer of a `.uwx` stay one implementation wherever they can.
21
+ *
22
+ * ## Two things measured on a real payload, both of which would break a naive reader
23
+ *
24
+ * 1. ⛔ **The archive is NOT "stored".** `pull.js` describes the format as *"our
25
+ * Stored ZIP"*, and that is true of `manifest.json` and false of the entity
26
+ * files — measured on a live pull: `manifest.json` method 0 (STORED), the
27
+ * entity JSON method 8 (DEFLATED). A stored-only reader silently yields the
28
+ * manifest and drops the document, i.e. it returns success and no entities.
29
+ * **Both methods are handled below and the tests cover a mixed archive.**
30
+ * 2. The entity payload is the *only* thing wanted; `manifest.json` is skipped, as
31
+ * every other reader in the tree does.
32
+ *
33
+ * We read the **central directory** rather than walking local file headers,
34
+ * because a local header may carry zeroed sizes when the general-purpose bit 3
35
+ * flag defers them to a trailing data descriptor. The central directory always
36
+ * carries the real sizes.
37
+ */
38
+
39
+ import { inflateRawSync } from 'node:zlib'
40
+
41
+ const SIG_EOCD = 0x06054b50
42
+ const SIG_CENTRAL = 0x02014b50
43
+ const SIG_LOCAL = 0x04034b50
44
+ const METHOD_STORED = 0
45
+ const METHOD_DEFLATED = 8
46
+
47
+ /** ZIP local-file-header magic, "PK\x03\x04" — the first two bytes are enough. */
48
+ export function looksLikeZip(buf) {
49
+ return Boolean(buf) && buf.length >= 2 && buf[0] === 0x50 && buf[1] === 0x4b
50
+ }
51
+
52
+ /**
53
+ * Locate the end-of-central-directory record.
54
+ *
55
+ * Scanned backwards because the record is last and variable-length (it carries an
56
+ * optional trailing comment).
57
+ *
58
+ * @param {Buffer} buf
59
+ * @returns {number} offset of the EOCD, or -1
60
+ */
61
+ function findEocd(buf) {
62
+ const min = Math.max(0, buf.length - 22 - 0xffff)
63
+ for (let i = buf.length - 22; i >= min; i--) {
64
+ if (buf.readUInt32LE(i) === SIG_EOCD) return i
65
+ }
66
+ return -1
67
+ }
68
+
69
+ /**
70
+ * Read every entry out of a ZIP as `[name, Buffer]` pairs.
71
+ *
72
+ * @param {Buffer} buf
73
+ * @returns {Array<[string, Buffer]>} empty when the buffer is not a readable ZIP
74
+ */
75
+ export function readUwxZip(buf) {
76
+ if (!looksLikeZip(buf)) return []
77
+ const eocd = findEocd(buf)
78
+ if (eocd < 0) return []
79
+
80
+ const count = buf.readUInt16LE(eocd + 10)
81
+ let ptr = buf.readUInt32LE(eocd + 16)
82
+ const out = []
83
+
84
+ for (let i = 0; i < count; i++) {
85
+ if (ptr + 46 > buf.length || buf.readUInt32LE(ptr) !== SIG_CENTRAL) break
86
+
87
+ const method = buf.readUInt16LE(ptr + 10)
88
+ const csize = buf.readUInt32LE(ptr + 20)
89
+ const nameLen = buf.readUInt16LE(ptr + 28)
90
+ const extraLen = buf.readUInt16LE(ptr + 30)
91
+ const commentLen = buf.readUInt16LE(ptr + 32)
92
+ const localOff = buf.readUInt32LE(ptr + 42)
93
+ const name = buf.subarray(ptr + 46, ptr + 46 + nameLen).toString('utf8')
94
+
95
+ // The local header's name/extra lengths are independent of the central
96
+ // directory's — read them where the data actually starts, not from above.
97
+ if (localOff + 30 <= buf.length && buf.readUInt32LE(localOff) === SIG_LOCAL) {
98
+ const lNameLen = buf.readUInt16LE(localOff + 26)
99
+ const lExtraLen = buf.readUInt16LE(localOff + 28)
100
+ const start = localOff + 30 + lNameLen + lExtraLen
101
+ const raw = buf.subarray(start, start + csize)
102
+ try {
103
+ if (method === METHOD_STORED) out.push([name, Buffer.from(raw)])
104
+ else if (method === METHOD_DEFLATED) out.push([name, inflateRawSync(raw)])
105
+ // any other method: skip rather than guess
106
+ } catch {
107
+ /* a corrupt entry must not lose the readable ones */
108
+ }
109
+ }
110
+
111
+ ptr += 46 + nameLen + extraLen + commentLen
112
+ }
113
+
114
+ return out
115
+ }
116
+
117
+ /**
118
+ * The entity `$`-documents inside a `.uwx`, or a JSON body's documents.
119
+ *
120
+ * Mirrors `pull.js`'s `readPullDocuments` in behaviour — including its JSON
121
+ * fallback, so a future envelope change does not break this lane either — but
122
+ * without the `@uniweb/build` import. See the header for why that matters.
123
+ *
124
+ * @param {Buffer} buf
125
+ * @returns {object[]} parsed documents, possibly empty
126
+ */
127
+ export function readUwxDocuments(buf) {
128
+ if (!buf || buf.length === 0) return []
129
+
130
+ if (looksLikeZip(buf)) {
131
+ const docs = []
132
+ for (const [name, data] of readUwxZip(buf)) {
133
+ if (name === 'manifest.json' || !name.endsWith('.json')) continue
134
+ try {
135
+ docs.push(JSON.parse(data.toString('utf8')))
136
+ } catch {
137
+ /* skip a non-document entry */
138
+ }
139
+ }
140
+ return docs
141
+ }
142
+
143
+ let payload
144
+ try {
145
+ payload = JSON.parse(buf.toString('utf8'))
146
+ } catch {
147
+ return []
148
+ }
149
+ if (Array.isArray(payload)) return payload.filter(Boolean)
150
+ const list = Array.isArray(payload?.entities)
151
+ ? payload.entities
152
+ : Array.isArray(payload?.documents)
153
+ ? payload.documents
154
+ : null
155
+ if (list) return list.filter(Boolean)
156
+ return payload ? [payload] : []
157
+ }