uniweb 0.16.4 → 0.17.0

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.
@@ -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
+ }