uniweb 0.16.4 → 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 +6 -6
- package/src/backend/site-sync.js +71 -3
- package/src/commands/clone.js +14 -4
- package/src/commands/publish.js +14 -2
- package/src/commands/push.js +14 -3
- package/src/framework-index.json +8 -8
- package/src/index.js +5 -0
- package/src/utils/uwx-read.js +157 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.16.
|
|
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.
|
|
45
|
-
"@uniweb/kit": "^0.11.
|
|
46
|
-
"@uniweb/runtime": "^0.11.
|
|
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.
|
|
49
|
+
"@uniweb/build": "^0.18.5",
|
|
50
50
|
"@uniweb/content-reader": "^1.2.2",
|
|
51
|
-
"@uniweb/semantic-parser": "^1.2.
|
|
51
|
+
"@uniweb/semantic-parser": "^1.2.2"
|
|
52
52
|
},
|
|
53
53
|
"peerDependenciesMeta": {
|
|
54
54
|
"@uniweb/build": {
|
package/src/backend/site-sync.js
CHANGED
|
@@ -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
|
-
|
|
488
|
-
|
|
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
|
package/src/commands/clone.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 }
|
package/src/commands/publish.js
CHANGED
|
@@ -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 })
|
package/src/commands/push.js
CHANGED
|
@@ -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 })
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-08-
|
|
3
|
+
"generatedAt": "2026-08-12T22:01:32.128Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.18.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|