@quillmark/quiver 0.29.0 → 0.31.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.
@@ -1,113 +1,147 @@
1
1
  /**
2
- * Built-quiver loader: reads a packed artifact through a transport, validates
3
- * its pointer and manifest, and hands back a Quiver whose loader fetches
4
- * bundles and fonts on demand, each checked against the digest in its name.
2
+ * Built-quiver loader: reads `quiver.json` through a reader, validates it, and hands back
3
+ * a Quiver whose loader reads bundles and fonts on demand.
5
4
  *
6
5
  * Package-internal, and browser-safe: no static `node:` imports at any level.
7
6
  */
8
7
  import { QuiverError } from './errors.js';
9
- import { MAX_BUNDLE_BYTES, unpackFiles } from './bundle.js';
8
+ import { unpackFiles } from './bundle.js';
10
9
  import { isQuillName } from './ref.js';
11
10
  import { isCanonicalSemver, compareSemver } from './semver.js';
12
- import { NAME_DIGEST_LENGTH, sha256Hex } from './digest.js';
13
- import { MANIFEST_VERSION, POINTER_FORMAT } from './format.js';
11
+ import { FORMAT, INDEX } from './format.js';
12
+ import { isFont, isZip } from './signature.js';
14
13
  import { createQuiver } from './quiver.js';
15
- /**
16
- * The ceiling per path. A bundle's is the unpack budget itself (`bundle.ts`). The other
17
- * two are their own: a font is a store entry rather than a bundle entry, and the pointer
18
- * and the manifest are the small documents a catalog is read out of — the pointer being
19
- * the one response in the artifact with no digest behind it.
20
- */
21
- const MAX_DOCUMENT_BYTES = 8 * 1024 * 1024;
22
- const MAX_FONT_BYTES = 32 * 1024 * 1024;
23
- /**
24
- * Fetch under `maxBytes`, then check the bytes against the digest their name
25
- * carries. That check is what makes "content-addressed, safe to cache forever"
26
- * a property rather than a hope: it catches a corrupted CDN object, a partial
27
- * sync, and a name reused across releases. A mismatch is a transport failure
28
- * (the bytes that arrived are not the bytes asked for), so the caches that
29
- * evict on error let a retry succeed.
30
- *
31
- * The ceiling is the transport's to hold, because a digest is a verdict on
32
- * bytes that have all arrived.
33
- *
34
- * Where no digest primitive exists (a page served over plain http, which is
35
- * not a secure context) the fetch passes through unchecked.
36
- */
37
- async function fetchVerified(transport, path, expected, maxBytes) {
38
- const bytes = await transport.fetchBytes(path, { maxBytes });
39
- const actual = await sha256Hex(bytes);
40
- if (actual !== undefined && !actual.startsWith(expected)) {
41
- throw new QuiverError('transport_error', `Digest mismatch for "${path}": the bytes hash to ${actual.slice(0, expected.length)}, not ${expected}`);
42
- }
43
- return bytes;
14
+ export function httpReader(base) {
15
+ const root = base.endsWith('/') ? base : `${base}/`;
16
+ return async (path, mode) => {
17
+ const url = `${root}${path}`;
18
+ let response;
19
+ try {
20
+ response = await globalThis.fetch(url, { cache: mode });
21
+ }
22
+ catch (err) {
23
+ throw new QuiverError('transport_error', `Network error fetching "${url}": ${err.message}`, { cause: err });
24
+ }
25
+ if (!response.ok) {
26
+ throw new QuiverError('transport_error', `HTTP ${response.status} fetching "${url}"`);
27
+ }
28
+ try {
29
+ return new Uint8Array(await response.arrayBuffer());
30
+ }
31
+ catch (err) {
32
+ throw new QuiverError('transport_error', `Network error reading "${url}": ${err.message}`, { cause: err });
33
+ }
34
+ };
44
35
  }
45
- // ─── Path validation ──────────────────────────────────────────────────────────
46
- // Each name carries the digest of what it names; the capture group is the
47
- // digest the fetch is checked against, so path validation and verification
48
- // read the same character span. `build` writes exactly NAME_DIGEST_LENGTH
49
- // chars for bundles and manifests and the full 64 for store entries.
50
- const DIGEST = `[0-9a-f]{${NAME_DIGEST_LENGTH},}`;
51
- const MANIFEST_FILENAME_RE = new RegExp(`^manifest\\.(${DIGEST})\\.json$`);
52
- // The name span is the ref charset, not a filename charset: `build` spells a bundle
53
- // off the catalog row, and a row is a name a ref can spell.
54
- const BUNDLE_FILENAME_RE = new RegExp(`^[A-Za-z0-9_-]+@[0-9]+\\.[0-9]+\\.[0-9]+\\.(${DIGEST})\\.zip$`);
36
+ /** Over bytes the caller holds; a leading `./` or `/` on a key is dropped. */
37
+ export function filesReader(files) {
38
+ const held = new Map([...files].map(([key, bytes]) => [key.replace(/^\.?\//, ''), bytes]));
39
+ return async (path) => {
40
+ const bytes = held.get(path);
41
+ if (bytes === undefined) {
42
+ throw new QuiverError('transport_error', `No bytes held for "${path}": Quiver.fromBuiltFiles needs the whole artifact`);
43
+ }
44
+ return bytes;
45
+ };
46
+ }
47
+ // The shapes `build` writes. Neither admits a separator, so a validated name read off
48
+ // `quiver.json` stays inside the artifact on every reader.
49
+ const BUNDLE_RE = /^[A-Za-z0-9_-]+@[0-9]+\.[0-9]+\.[0-9]+\.[0-9a-f]+\.zip$/;
55
50
  const FONT_HASH_RE = /^[0-9a-f]{64}$/;
56
- /** The digest a content-addressed filename carries. Throws `quiver_invalid`. */
57
- function digestOfName(re, name, what) {
58
- const match = re.exec(name);
59
- if (match === null) {
60
- throw new QuiverError('quiver_invalid', `${what} is invalid: "${name}"`);
61
- }
62
- return match[1];
51
+ /** Bytes a host answered with that are not the file named: a `transport_error`, so the
52
+ * reread below gets its turn. */
53
+ function refused(path, what) {
54
+ return new QuiverError('transport_error', `"${path}" arrived as something other than ${what}`);
63
55
  }
64
- function validateFontHash(hash, context) {
65
- if (!FONT_HASH_RE.test(hash)) {
66
- throw new QuiverError('quiver_invalid', `${context}: font hash is invalid: "${hash}"`);
56
+ /** A cached read that fails, by transport or by `opens`, is read once more past the cache,
57
+ * which replaces what the cache held. */
58
+ async function readChecked(read, path, opens, what) {
59
+ try {
60
+ const cached = await read(path, 'force-cache');
61
+ if (opens(cached))
62
+ return cached;
67
63
  }
64
+ catch {
65
+ // The reload's answer is the one that stands.
66
+ }
67
+ const bytes = await read(path, 'reload');
68
+ if (!opens(bytes))
69
+ throw refused(path, what);
70
+ return bytes;
68
71
  }
69
- // ─── BuiltLoader implementation ─────────────────────────────────────────────
70
72
  class BuiltLoader {
71
- transport;
72
- index;
73
- /** Font byte cache: hash → in-flight or resolved Promise. */
74
- fontCache = new Map();
75
- constructor(transport,
76
- /** Index map from "name@version" to its manifest entry. */
77
- index) {
78
- this.transport = transport;
79
- this.index = index;
73
+ read;
74
+ entries;
75
+ #fonts = new Map();
76
+ #rereading;
77
+ constructor(read, entries) {
78
+ this.read = read;
79
+ this.entries = entries;
80
80
  }
81
+ /**
82
+ * A tab holding the previous generation's index asks for names the next one deleted,
83
+ * so a failed read rereads `quiver.json` once and retries under that entry where it
84
+ * names other files. A reread refusing the index is the error that explains the
85
+ * failure, and is the one thrown.
86
+ */
81
87
  async loadTree(name, version) {
82
- // The catalog the outer Quiver resolves against derives from this index,
83
- // so every ref reaching here has an entry.
84
- const entry = this.index.get(`${name}@${version}`);
85
- const zipBytes = await fetchVerified(this.transport, entry.bundle, digestOfName(BUNDLE_FILENAME_RE, entry.bundle, 'bundle filename'), MAX_BUNDLE_BYTES);
86
- const files = unpackFiles(zipBytes);
87
- const fontEntries = Object.entries(entry.fonts);
88
- await Promise.all(fontEntries.map(async ([path, hash]) => {
89
- files[path] = await this.fetchFont(hash);
88
+ const key = `${name}@${version}`;
89
+ const entry = this.entries.get(key);
90
+ try {
91
+ return await this.#treeOf(entry);
92
+ }
93
+ catch (err) {
94
+ let index;
95
+ try {
96
+ index = await this.#reread();
97
+ }
98
+ catch (reread) {
99
+ throw reread instanceof QuiverError && reread.code === 'quiver_invalid' ? reread : err;
100
+ }
101
+ const fresh = index.entries.get(key);
102
+ if (fresh === undefined || sameFiles(fresh, entry))
103
+ throw err;
104
+ return this.#treeOf(fresh);
105
+ }
106
+ }
107
+ /** One reread in flight, taking every entry both generations carry. */
108
+ #reread() {
109
+ return (this.#rereading ??= readIndex(this.read)
110
+ .then((index) => {
111
+ for (const [key, entry] of index.entries) {
112
+ if (this.entries.has(key))
113
+ this.entries.set(key, entry);
114
+ }
115
+ return index;
116
+ })
117
+ .finally(() => {
118
+ this.#rereading = undefined;
119
+ }));
120
+ }
121
+ async #treeOf(entry) {
122
+ const zip = await readChecked(this.read, entry.bundle, isZip, 'a zip');
123
+ const files = unpackFiles(zip);
124
+ await Promise.all(Object.entries(entry.fonts).map(async ([path, hash]) => {
125
+ files[path] = await this.#font(hash, path);
90
126
  }));
91
127
  return new Map(Object.entries(files));
92
128
  }
93
- /**
94
- * Fetch a font by hash from store/<hash>, coalescing concurrent requests for
95
- * the same hash into a single fetch. On error, removes the cache entry so
96
- * callers can retry.
97
- */
98
- fetchFont(hash) {
99
- let promise = this.fontCache.get(hash);
100
- if (!promise) {
101
- promise = fetchVerified(this.transport, `store/${hash}`, hash, MAX_FONT_BYTES).catch((err) => {
102
- this.fontCache.delete(hash);
129
+ /** One read per hash, coalesced; a failure is evicted so a retry reads again. */
130
+ #font(hash, rel) {
131
+ let bytes = this.#fonts.get(hash);
132
+ if (bytes === undefined) {
133
+ bytes = readChecked(this.read, `fonts/${hash}`, (font) => isFont(font, rel), 'a font').catch((err) => {
134
+ this.#fonts.delete(hash);
103
135
  throw err;
104
136
  });
105
- this.fontCache.set(hash, promise);
137
+ this.#fonts.set(hash, bytes);
106
138
  }
107
- return promise;
139
+ return bytes;
108
140
  }
109
141
  }
110
- // ─── Pointer + manifest validation helpers ────────────────────────────────────
142
+ function sameFiles(a, b) {
143
+ return a.bundle === b.bundle && JSON.stringify(a.fonts) === JSON.stringify(b.fonts);
144
+ }
111
145
  function assertNoUnknownKeys(obj, allowed, context) {
112
146
  for (const key of Object.keys(obj)) {
113
147
  if (!allowed.includes(key)) {
@@ -115,118 +149,106 @@ function assertNoUnknownKeys(obj, allowed, context) {
115
149
  }
116
150
  }
117
151
  }
118
- function parsePointer(raw) {
119
- let parsed;
152
+ async function readIndex(read) {
153
+ let bytes;
120
154
  try {
121
- parsed = JSON.parse(raw);
122
- }
123
- catch {
124
- throw new QuiverError('quiver_invalid', 'latest.json contains invalid JSON');
155
+ bytes = await read(INDEX, 'no-cache');
125
156
  }
126
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
127
- throw new QuiverError('quiver_invalid', 'latest.json must be a JSON object');
128
- }
129
- const obj = parsed;
130
- // The format, read before anything is believed about the rest. An unknown key is not
131
- // a failure here: this is the document a newer format announces itself on, and a
132
- // reader rejecting what it does not recognise refuses the announcement too. Absent
133
- // means a build from before the marker (`format.ts`).
134
- const format = obj['format'] ?? POINTER_FORMAT;
135
- if (typeof format !== 'number' || !Number.isInteger(format) || format < 1) {
136
- throw new QuiverError('quiver_invalid', `latest.json: "format" must be a positive integer, got ${JSON.stringify(obj['format'])}`);
137
- }
138
- if (format > POINTER_FORMAT) {
139
- throw new QuiverError('quiver_invalid', `This quiver is built in format ${format} and this loader reads ${POINTER_FORMAT}. ` +
140
- `Upgrade @quillmark/quiver, or for a served client the client itself, which carries the copy that reads this.`);
141
- }
142
- const manifest = obj['manifest'];
143
- if (typeof manifest !== 'string' || manifest.length === 0) {
144
- throw new QuiverError('quiver_invalid', 'latest.json must have a non-empty string "manifest" field');
157
+ catch (err) {
158
+ if (err instanceof QuiverError)
159
+ throw err;
160
+ throw new QuiverError('transport_error', `Failed to read ${INDEX}: ${err.message}`, {
161
+ cause: err
162
+ });
145
163
  }
146
- return manifest;
164
+ return parseIndex(new TextDecoder().decode(bytes));
147
165
  }
148
- function parseManifest(raw) {
166
+ function parseIndex(raw) {
149
167
  let parsed;
150
168
  try {
151
169
  parsed = JSON.parse(raw);
152
170
  }
153
171
  catch {
154
- throw new QuiverError('quiver_invalid', 'Manifest file contains invalid JSON');
172
+ throw new QuiverError('quiver_invalid', `${INDEX} contains invalid JSON`);
155
173
  }
156
174
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
157
- throw new QuiverError('quiver_invalid', 'Manifest must be a JSON object');
175
+ throw new QuiverError('quiver_invalid', `${INDEX} must be a JSON object`);
158
176
  }
159
177
  const obj = parsed;
160
- assertNoUnknownKeys(obj, ['version', 'name', 'description', 'quills'], 'manifest');
161
- const version = obj['version'];
162
- if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) {
163
- throw new QuiverError('quiver_invalid', `Manifest version must be a positive integer, got ${JSON.stringify(version)}`);
178
+ // Read before the key check, so a newer document is refused as newer rather than as
179
+ // carrying a field this reader does not know.
180
+ const format = obj['format'];
181
+ if (typeof format !== 'number' || !Number.isInteger(format) || format < 1) {
182
+ throw new QuiverError('quiver_invalid', `${INDEX}: "format" must be a positive integer, got ${JSON.stringify(format)}`);
164
183
  }
165
- if (version > MANIFEST_VERSION) {
166
- throw new QuiverError('quiver_invalid', `This quiver's manifest is version ${version} and this loader reads ${MANIFEST_VERSION}. ` +
184
+ if (format > FORMAT) {
185
+ throw new QuiverError('quiver_invalid', `This quiver is built in format ${format} and this loader reads ${FORMAT}. ` +
167
186
  `Upgrade @quillmark/quiver, or for a served client the client itself, which carries the copy that reads this.`);
168
187
  }
188
+ assertNoUnknownKeys(obj, ['format', 'name', 'description', 'quills'], INDEX);
169
189
  const name = obj['name'];
170
190
  if (typeof name !== 'string' || name.length === 0) {
171
- throw new QuiverError('quiver_invalid', 'Manifest must have a non-empty string "name" field');
191
+ throw new QuiverError('quiver_invalid', `${INDEX} must have a non-empty string "name" field`);
172
192
  }
173
193
  const description = obj['description'];
174
194
  if (description !== undefined && typeof description !== 'string') {
175
- throw new QuiverError('quiver_invalid', `Manifest "description" must be a string if present, got ${typeof description}`);
195
+ throw new QuiverError('quiver_invalid', `${INDEX} "description" must be a string if present, got ${typeof description}`);
176
196
  }
177
- const entries = obj['quills'];
178
- if (!Array.isArray(entries)) {
179
- throw new QuiverError('quiver_invalid', 'Manifest must have a "quills" array');
197
+ const quills = obj['quills'];
198
+ if (!Array.isArray(quills)) {
199
+ throw new QuiverError('quiver_invalid', `${INDEX} must have a "quills" array`);
180
200
  }
181
- const quills = [];
182
- for (let i = 0; i < entries.length; i++) {
183
- const entry = entries[i];
201
+ const entries = new Map();
202
+ for (let i = 0; i < quills.length; i++) {
203
+ const at = `${INDEX} quills[${i}]`;
204
+ const entry = quills[i];
184
205
  if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
185
- throw new QuiverError('quiver_invalid', `manifest.quills[${i}] must be an object`);
206
+ throw new QuiverError('quiver_invalid', `${at} must be an object`);
186
207
  }
187
208
  const e = entry;
188
- assertNoUnknownKeys(e, ['name', 'version', 'bundle', 'fonts'], `manifest.quills[${i}]`);
209
+ assertNoUnknownKeys(e, ['name', 'version', 'bundle', 'fonts'], at);
189
210
  const quillName = e['name'];
190
211
  if (typeof quillName !== 'string' || quillName.length === 0) {
191
- throw new QuiverError('quiver_invalid', `manifest.quills[${i}].name must be a non-empty string`);
212
+ throw new QuiverError('quiver_invalid', `${at}.name must be a non-empty string`);
192
213
  }
193
214
  if (!isQuillName(quillName)) {
194
- throw new QuiverError('quiver_invalid', `manifest.quills[${i}].name "${quillName}" is not a name a ref can spell — only [A-Za-z0-9_-] are allowed`);
215
+ throw new QuiverError('quiver_invalid', `${at}.name "${quillName}" is not a name a ref can spell — only [A-Za-z0-9_-] are allowed`);
195
216
  }
196
217
  const version = e['version'];
197
218
  if (typeof version !== 'string' || !isCanonicalSemver(version)) {
198
- throw new QuiverError('quiver_invalid', `manifest.quills[${i}].version must be canonical semver (x.y.z), got "${String(version)}"`);
219
+ throw new QuiverError('quiver_invalid', `${at}.version must be canonical semver (x.y.z), got "${String(version)}"`);
199
220
  }
200
221
  const bundle = e['bundle'];
201
- if (typeof bundle !== 'string' || bundle.length === 0) {
202
- throw new QuiverError('quiver_invalid', `manifest.quills[${i}].bundle must be a non-empty string`);
222
+ if (typeof bundle !== 'string' || !BUNDLE_RE.test(bundle)) {
223
+ throw new QuiverError('quiver_invalid', `${at}.bundle is invalid: "${String(bundle)}"`);
203
224
  }
204
- digestOfName(BUNDLE_FILENAME_RE, bundle, `manifest.quills[${i}].bundle filename`);
205
225
  const declared = e['fonts'];
206
226
  if (typeof declared !== 'object' || declared === null || Array.isArray(declared)) {
207
- throw new QuiverError('quiver_invalid', `manifest.quills[${i}].fonts must be an object`);
227
+ throw new QuiverError('quiver_invalid', `${at}.fonts must be an object`);
208
228
  }
209
229
  const fonts = {};
210
- for (const [k, v] of Object.entries(declared)) {
211
- if (typeof v !== 'string') {
212
- throw new QuiverError('quiver_invalid', `manifest.quills[${i}].fonts["${k}"] must be a string`);
230
+ for (const [path, hash] of Object.entries(declared)) {
231
+ if (typeof hash !== 'string' || !FONT_HASH_RE.test(hash)) {
232
+ throw new QuiverError('quiver_invalid', `${at}.fonts["${path}"] is not a font hash: ${JSON.stringify(hash)}`);
213
233
  }
214
- validateFontHash(v, `manifest.quills[${i}].fonts["${k}"]`);
215
- fonts[k] = v;
234
+ fonts[path] = hash;
216
235
  }
217
- quills.push({ name: quillName, version, bundle, fonts });
236
+ const key = `${quillName}@${version}`;
237
+ if (entries.has(key)) {
238
+ throw new QuiverError('quiver_invalid', `Duplicate quill entry in ${INDEX}: "${key}"`);
239
+ }
240
+ entries.set(key, { name: quillName, version, bundle, fonts });
218
241
  }
219
- return { name, description, quills };
242
+ return { name, description, entries };
220
243
  }
221
- // ─── Catalog assembly ─────────────────────────────────────────────────────────
222
244
  /**
223
- * Name → versions (descending), derived from the index. One structure backs
224
- * both the loader's lookups and the Quiver's catalog, so the two cannot
225
- * disagree about what the manifest holds.
245
+ * Name → versions (descending), derived from the entries. One structure backs both the
246
+ * loader's lookups and the Quiver's catalog, so the two cannot disagree about what
247
+ * `quiver.json` holds.
226
248
  */
227
- function catalogOf(index) {
249
+ function catalogOf(entries) {
228
250
  const catalog = new Map();
229
- for (const entry of index.values()) {
251
+ for (const entry of entries.values()) {
230
252
  const versions = catalog.get(entry.name) ?? [];
231
253
  versions.push(entry.version);
232
254
  catalog.set(entry.name, versions);
@@ -236,42 +258,7 @@ function catalogOf(index) {
236
258
  }
237
259
  return catalog;
238
260
  }
239
- // ─── Main entry point ─────────────────────────────────────────────────────────
240
- /** Load a build-output quiver via the given transport. */
241
- export async function loadBuiltQuiver(transport) {
242
- // The pointer is the one name that is not content-addressed, so it is also the
243
- // one fetch that must revalidate.
244
- let pointerBytes;
245
- try {
246
- pointerBytes = await transport.fetchBytes('latest.json', {
247
- revalidate: true,
248
- maxBytes: MAX_DOCUMENT_BYTES
249
- });
250
- }
251
- catch (err) {
252
- if (err instanceof QuiverError)
253
- throw err;
254
- throw new QuiverError('transport_error', `Failed to fetch latest.json: ${err.message}`, { cause: err });
255
- }
256
- const manifestFileName = parsePointer(new TextDecoder().decode(pointerBytes));
257
- const manifestDigest = digestOfName(MANIFEST_FILENAME_RE, manifestFileName, 'Pointer manifest filename');
258
- let manifestBytes;
259
- try {
260
- manifestBytes = await fetchVerified(transport, manifestFileName, manifestDigest, MAX_DOCUMENT_BYTES);
261
- }
262
- catch (err) {
263
- if (err instanceof QuiverError)
264
- throw err;
265
- throw new QuiverError('transport_error', `Failed to fetch manifest "${manifestFileName}": ${err.message}`, { cause: err });
266
- }
267
- const manifest = parseManifest(new TextDecoder().decode(manifestBytes));
268
- const index = new Map();
269
- for (const entry of manifest.quills) {
270
- const key = `${entry.name}@${entry.version}`;
271
- if (index.has(key)) {
272
- throw new QuiverError('quiver_invalid', `Duplicate quill entry in manifest: "${key}"`);
273
- }
274
- index.set(key, entry);
275
- }
276
- return createQuiver(manifest.name, manifest.description, catalogOf(index), new BuiltLoader(transport, index));
261
+ export async function loadBuiltQuiver(read) {
262
+ const { name, description, entries } = await readIndex(read);
263
+ return createQuiver(name, description, catalogOf(entries), new BuiltLoader(read, entries));
277
264
  }
package/dist/bundle.d.ts CHANGED
@@ -1,22 +1,6 @@
1
1
  /**
2
2
  * Zip utilities — browser-safe (uses fflate only).
3
3
  */
4
- /**
5
- * What a bundle may hold. Deflate tops out near 1032:1, so a megabyte of zip is a
6
- * gigabyte resident and the bundle is what a reader inflates to find that out.
7
- *
8
- * The ceiling is a constant rather than something a consumer raises, because a quiver
9
- * is a dependency pinned like any other (README, "What a quiver is trusted to be") and
10
- * what a budget bounds there is a build that packed something enormous. Both ends spend
11
- * it, so an artifact no loader would read is refused where it is written.
12
- *
13
- * Fonts are not in here: `build` writes them to the store and the manifest names them,
14
- * leaving a bundle its markdown, its Typst, and what those inline.
15
- *
16
- * `built-loader.ts` spends it on the wire too. A deflate of what fits inside it is within
17
- * zip framing of that, so one number bounds both ends and the fetch needs no second.
18
- */
19
- export declare const MAX_BUNDLE_BYTES: number;
20
4
  /**
21
5
  * Pack a flat file map into a deterministic zip.
22
6
  * Keys are sorted before zipping so insertion order doesn't affect output.
package/dist/bundle.js CHANGED
@@ -23,13 +23,10 @@ const ZIP_EPOCH = new Date(1980, 0, 1, 0, 0, 0, 0);
23
23
  * what a budget bounds there is a build that packed something enormous. Both ends spend
24
24
  * it, so an artifact no loader would read is refused where it is written.
25
25
  *
26
- * Fonts are not in here: `build` writes them to the store and the manifest names them,
26
+ * Fonts are not in here: `build` writes them under `fonts/` and `quiver.json` names them,
27
27
  * leaving a bundle its markdown, its Typst, and what those inline.
28
- *
29
- * `built-loader.ts` spends it on the wire too. A deflate of what fits inside it is within
30
- * zip framing of that, so one number bounds both ends and the fetch needs no second.
31
28
  */
32
- export const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
29
+ const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
33
30
  const MAX_ENTRY_BYTES = 16 * 1024 * 1024;
34
31
  const MAX_BUNDLE_ENTRIES = 2048;
35
32
  /**
@@ -38,8 +35,8 @@ const MAX_BUNDLE_ENTRIES = 2048;
38
35
  * Unpacking spends the sizes the central directory declares, and those are the sizes
39
36
  * fflate allocates: each entry inflates into a buffer of exactly its declared size,
40
37
  * which never grows. So a header cannot buy more than it declares — it can only
41
- * understate itself and truncate its own entry, which is the corrupt bundle the digest
42
- * already answers.
38
+ * understate itself and truncate its own entry, which is a corrupt bundle rather than an
39
+ * allocation.
43
40
  */
44
41
  function bundleBudget() {
45
42
  let total = 0;
package/dist/format.d.ts CHANGED
@@ -1,21 +1,15 @@
1
+ /** The one name a reader fetches that carries no digest: the catalog, and where every
2
+ * other name is read off. */
3
+ export declare const INDEX = "quiver.json";
1
4
  /**
2
- * The built artifact's format, stamped into `latest.json` and refused to be read past
3
- * (QUIVER §"The pointer" for why the pointer carries it and reads past keys it does not
4
- * know).
5
- *
6
- * It moves when a reader assuming it would misread a tree rather than fail on it: a
7
- * renamed pointer field, a second pointer, a store keyed by something other than the
8
- * full hash. A change confined to the manifest's own shape moves the manifest's
9
- * `version` instead.
5
+ * The artifact's format, stamped into `quiver.json` and refused to be read past (QUIVER
6
+ * §"The index"). Any change a reader assuming the previous shape would misread or reject
7
+ * moves it: `quiver.json` is closed, so an added field is one of those.
10
8
  */
11
- export declare const POINTER_FORMAT = 1;
9
+ export declare const FORMAT = 2;
12
10
  /**
13
- * The manifest's shape, stated in the document and refused to be read past. Version 2
14
- * carries the quiver's `description`; version 1 is the same document without it.
15
- *
16
- * The manifest is closed, so a field added to it is a field an older reader rejects as an
17
- * unknown key, naming neither what happened nor what to do. The version is what that
18
- * reader reads first instead, and a manifest above its own is refused with the upgrade
19
- * named.
11
+ * What a reader of format 1 fetches first. It reads `format` off it before anything else,
12
+ * so a file carrying this format and nothing more is refused there with the upgrade named,
13
+ * rather than as a missing file. No reader of this format reads it.
20
14
  */
21
- export declare const MANIFEST_VERSION = 2;
15
+ export declare const LEGACY_POINTER = "latest.json";
package/dist/format.js CHANGED
@@ -1,21 +1,15 @@
1
+ /** The one name a reader fetches that carries no digest: the catalog, and where every
2
+ * other name is read off. */
3
+ export const INDEX = 'quiver.json';
1
4
  /**
2
- * The built artifact's format, stamped into `latest.json` and refused to be read past
3
- * (QUIVER §"The pointer" for why the pointer carries it and reads past keys it does not
4
- * know).
5
- *
6
- * It moves when a reader assuming it would misread a tree rather than fail on it: a
7
- * renamed pointer field, a second pointer, a store keyed by something other than the
8
- * full hash. A change confined to the manifest's own shape moves the manifest's
9
- * `version` instead.
5
+ * The artifact's format, stamped into `quiver.json` and refused to be read past (QUIVER
6
+ * §"The index"). Any change a reader assuming the previous shape would misread or reject
7
+ * moves it: `quiver.json` is closed, so an added field is one of those.
10
8
  */
11
- export const POINTER_FORMAT = 1;
9
+ export const FORMAT = 2;
12
10
  /**
13
- * The manifest's shape, stated in the document and refused to be read past. Version 2
14
- * carries the quiver's `description`; version 1 is the same document without it.
15
- *
16
- * The manifest is closed, so a field added to it is a field an older reader rejects as an
17
- * unknown key, naming neither what happened nor what to do. The version is what that
18
- * reader reads first instead, and a manifest above its own is refused with the upgrade
19
- * named.
11
+ * What a reader of format 1 fetches first. It reads `format` off it before anything else,
12
+ * so a file carrying this format and nothing more is refused there with the upgrade named,
13
+ * rather than as a missing file. No reader of this format reads it.
20
14
  */
21
- export const MANIFEST_VERSION = 2;
15
+ export const LEGACY_POINTER = 'latest.json';
package/dist/node.d.ts CHANGED
@@ -28,7 +28,7 @@ import type { BuildOptions } from './build.js';
28
28
  export declare function fromDir(pathOrFileUrl: string): Promise<Quiver>;
29
29
  /**
30
30
  * Loads a packed (build-output) quiver from a local directory containing
31
- * `latest.json` and the manifest/bundle/store files written by `build`.
31
+ * `quiver.json` and the bundles and fonts written by `build`.
32
32
  * Symmetric to `Quiver.fromBuiltUrl(url)` but reads from disk instead of HTTP:
33
33
  * no network required.
34
34
  *
package/dist/node.js CHANGED
@@ -12,7 +12,9 @@ import { createQuiver } from './quiver.js';
12
12
  import { scanSourceQuiver, SourceLoader } from './source-loader.js';
13
13
  import { buildQuiver } from './build.js';
14
14
  import { loadBuiltQuiver } from './built-loader.js';
15
- import { FsBuiltTransport } from './transports/fs-built-transport.js';
15
+ import { QuiverError } from './errors.js';
16
+ import { readFile } from 'node:fs/promises';
17
+ import { join } from 'node:path';
16
18
  import { fileURLToPath } from 'node:url';
17
19
  /**
18
20
  * Reads a Source Quiver from a local directory containing `Quiver.yaml` and
@@ -38,7 +40,7 @@ export async function fromDir(pathOrFileUrl) {
38
40
  }
39
41
  /**
40
42
  * Loads a packed (build-output) quiver from a local directory containing
41
- * `latest.json` and the manifest/bundle/store files written by `build`.
43
+ * `quiver.json` and the bundles and fonts written by `build`.
42
44
  * Symmetric to `Quiver.fromBuiltUrl(url)` but reads from disk instead of HTTP:
43
45
  * no network required.
44
46
  *
@@ -49,7 +51,16 @@ export async function fromDir(pathOrFileUrl) {
49
51
  * Throws `quiver_invalid` on format errors, `transport_error` on I/O failure.
50
52
  */
51
53
  export async function fromBuiltDir(dirPath) {
52
- return loadBuiltQuiver(new FsBuiltTransport(dirPath));
54
+ return loadBuiltQuiver(async (path) => {
55
+ const file = join(dirPath, path);
56
+ try {
57
+ const buf = await readFile(file);
58
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
59
+ }
60
+ catch (err) {
61
+ throw new QuiverError('transport_error', `Failed to read "${file}": ${err.message}`, { cause: err });
62
+ }
63
+ });
53
64
  }
54
65
  /**
55
66
  * Reads the Source Quiver at sourceDir, validates it, and writes the runtime