@quillmark/quiver 0.26.0 → 0.28.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.
package/README.md CHANGED
@@ -47,7 +47,7 @@ const result = await engine.render(quill, doc, { format: 'pdf' });
47
47
 
48
48
  That quill is **borrowed, not owned**: every caller asking for that ref gets the same instance, so `free()` on it hands the next caller a freed handle. Code that owns its quill mints one from `(await quiver.getQuill(ref)).toTree()` and frees that.
49
49
 
50
- This package produces quills; `@quillmark/wasm` renders them. A quill from `getQuill` is engine-free portable data — schema inspection, validation, blueprint access, `seedDocument()` — and passes straight to `engine.render(quill, doc)`, which routes on `quill.backendId`, loads that backend, clones both handles into its memory, renders, and frees the clones. There is no boundary-crossing step to perform. `Engine.render`, `open`, `supportedFormats` and `supportsCanvas` are **async**. The canonical `Quill` / `Document` / `Engine` types are not re-exported here; import them from the `@quillmark/wasm` peer, their single source of truth.
50
+ This package produces quills; `@quillmark/wasm` renders them. A quill from `getQuill` is engine-free portable data — schema inspection, validation, blueprint access, `seedDocument()` — and passes straight to `engine.render(quill, doc)`, which routes on `quill.backendId`, loads that backend, clones both handles into its memory, renders, and frees the clones. There is no boundary-crossing step to perform. `Engine.render`, `open` and `supportedFormats` are **async**. The canonical `Quill` / `Document` / `Engine` types are not re-exported here; import them from the `@quillmark/wasm` peer, their single source of truth.
51
51
 
52
52
  One narrower verb sits beside `getQuill`: `resolve(ref)` returns the canonical ref without materializing anything, and is **sync** (the catalog is in memory from the moment the quiver is built).
53
53
 
@@ -124,7 +124,7 @@ The seed answers first and the URL serves what it does not carry. Seeding `lates
124
124
 
125
125
  ## The `latest.json` pointer
126
126
 
127
- `Quiver.fromBuiltUrl(url)` first fetches `<url>/latest.json`, a stable-named pointer to the current manifest. Everything behind that pointer is content-addressed and checked against the digest in its name; the pointer itself is not, so a cache layer can serve a stale one and silently pin the client to the old catalog. It is therefore the one request fetched `no-cache` (revalidate with the origin; a 304 still serves from disk), which closes the browser-cache layer. A stale CDN edge is answered by that host's cache headers.
127
+ `Quiver.fromBuiltUrl(url)` first fetches `<url>/latest.json`, a stable-named pointer to the current manifest. Everything behind that pointer is content-addressed and checked against the digest in its name; the pointer itself is not, so a cache layer can serve a stale one and silently pin the client to the old catalog. It is therefore the one request fetched `no-cache` (revalidate with the origin; a 304 still serves from disk), and every other request is `force-cache`, a digest-carrying name being entitled to whatever the cache already holds. Both are the browser layer only: a stale CDN edge is answered by that host's cache headers, and `quillkit`'s README states the whole contract for a deploy serving a client over one.
128
128
 
129
129
  The check itself needs `crypto.subtle`, which a browser exposes only in a secure context. An `https` page, `http://localhost` and Node have one; a page served over plain `http` to anything else — a dev host on a LAN address, a staging box without a certificate — has none, and there every fetch passes through unchecked.
130
130
 
package/dist/build.js CHANGED
@@ -76,16 +76,19 @@ function assertSafeOutDir(path, sourceDir, outDir) {
76
76
  */
77
77
  export async function buildQuiver(sourceDir, outDir, options = {}) {
78
78
  // Dynamic imports keep this module safe to type-import from browser contexts.
79
- const path = await import('node:path');
80
- const { dirname, join, resolve } = path;
79
+ const nodePath = await import('node:path');
80
+ const { dirname, join, resolve } = nodePath;
81
81
  const { mkdir, rename, rm, writeFile } = await import('node:fs/promises');
82
82
  const { existsSync } = await import('node:fs');
83
83
  const { createHash } = await import('node:crypto');
84
84
  const { scanSourceQuiver, readQuillTree } = await import('./source-loader.js');
85
- assertSafeOutDir(path, sourceDir, outDir);
85
+ assertSafeOutDir(nodePath, sourceDir, outDir);
86
86
  const out = resolve(outDir);
87
87
  const { stage, prev } = stagesOf(out);
88
88
  const { meta, catalog } = await scanSourceQuiver(sourceDir);
89
+ // True where the swap stepped the outgoing generation aside and could not put it
90
+ // back: `prev` then holds the only copy, and the sweep below would take it.
91
+ let prevHoldsLastGood = false;
89
92
  try {
90
93
  await packGeneration();
91
94
  }
@@ -94,7 +97,8 @@ export async function buildQuiver(sourceDir, outDir, options = {}) {
94
97
  // untouched. Neither tree outlives the call either way. Swept rather than
95
98
  // checked: a sweep that threw would answer for the build, and what a caller
96
99
  // needs to hear is why the build failed.
97
- for (const at of [stage, prev])
100
+ const sweep = prevHoldsLastGood ? [stage] : [stage, prev];
101
+ for (const at of sweep)
98
102
  await rm(at, { recursive: true, force: true }).catch(() => { });
99
103
  }
100
104
  /**
@@ -113,38 +117,35 @@ export async function buildQuiver(sourceDir, outDir, options = {}) {
113
117
  throw new QuiverError('transport_error', `Failed to prepare output directory "${outDir}": ${err.message}`, { cause: err });
114
118
  }
115
119
  const manifestQuills = [];
120
+ // Font hashes already in the store. The store is content-addressed, so a font
121
+ // shared across quills or versions is written once.
122
+ const stored = new Set();
116
123
  for (const [quillName, versions] of catalog) {
117
124
  for (const version of versions) {
118
125
  if (!options.drafts && isDraft(version))
119
126
  continue;
120
127
  const quillDir = join(sourceDir, 'quills', quillName, version);
121
128
  const tree = await readQuillTree(quillDir);
122
- const fontEntries = [];
123
- const contentEntries = [];
124
- for (const [path, bytes] of tree) {
125
- if (FONT_EXT.test(path)) {
126
- fontEntries.push([path, bytes]);
127
- }
128
- else {
129
- contentEntries.push([path, bytes]);
130
- }
131
- }
132
129
  const fonts = {};
133
- for (const [path, bytes] of fontEntries) {
130
+ const contentRecord = {};
131
+ for (const [rel, bytes] of tree) {
132
+ if (!FONT_EXT.test(rel)) {
133
+ contentRecord[rel] = bytes;
134
+ continue;
135
+ }
134
136
  // Full width: the store is keyed by hash, so two distinct fonts
135
137
  // sharing a prefix would merge into one entry.
136
138
  const hash = createHash('sha256').update(bytes).digest('hex');
139
+ fonts[rel] = hash;
140
+ if (stored.has(hash))
141
+ continue;
137
142
  try {
138
143
  await writeFile(join(stage, 'store', hash), bytes);
139
144
  }
140
145
  catch (err) {
141
146
  throw new QuiverError('transport_error', `Failed to write font store entry "${join(outDir, 'store', hash)}": ${err.message}`, { cause: err });
142
147
  }
143
- fonts[path] = hash;
144
- }
145
- const contentRecord = {};
146
- for (const [path, bytes] of contentEntries) {
147
- contentRecord[path] = bytes;
148
+ stored.add(hash);
148
149
  }
149
150
  // The budget `bundle.ts` carries is spent here as well as at the read, so
150
151
  // a quill no loader would take is refused by the build that packs it,
@@ -199,15 +200,27 @@ export async function buildQuiver(sourceDir, outDir, options = {}) {
199
200
  }
200
201
  // `prev` is cleared first: a rename onto a non-empty directory fails, and the
201
202
  // tree left by an interrupted run is one.
203
+ //
204
+ // Between them the outgoing generation is only at `prev`, so a second rename that
205
+ // throws — EXDEV across a mount, EBUSY on a tree a reader holds, ENOSPC — puts it
206
+ // back, and where the put-back fails leaves it there for the sweep to spare.
207
+ let steppedAside = false;
202
208
  try {
203
209
  await mkdir(dirname(out), { recursive: true });
204
210
  await rm(prev, { recursive: true, force: true });
205
- if (existsSync(out))
211
+ if (existsSync(out)) {
206
212
  await rename(out, prev);
213
+ steppedAside = true;
214
+ }
207
215
  await rename(stage, out);
208
216
  }
209
217
  catch (err) {
210
- throw new QuiverError('transport_error', `Failed to move the build into "${outDir}": ${err.message}`, { cause: err });
218
+ if (steppedAside)
219
+ prevHoldsLastGood = !(await rename(prev, out).then(() => true, () => false));
220
+ throw new QuiverError('transport_error', `Failed to move the build into "${outDir}": ${err.message}` +
221
+ (prevHoldsLastGood
222
+ ? `. The previous generation is at "${prev}" and has to be moved back by hand`
223
+ : ''), { cause: err });
211
224
  }
212
225
  }
213
226
  }
@@ -12,14 +12,21 @@ import type { Quiver } from './quiver.js';
12
12
  * (Node).
13
13
  */
14
14
  export interface BuiltTransport {
15
- fetchBytes(relativePath: string, opts?: FetchOptions): Promise<Uint8Array>;
15
+ fetchBytes(relativePath: string, opts: FetchOptions): Promise<Uint8Array>;
16
16
  }
17
17
  /**
18
18
  * `revalidate` marks the one request that must not be answered from a cache:
19
19
  * `latest.json`, the only name in the artifact that is not content-addressed.
20
- * Everything else is safe to cache forever by construction.
20
+ * Everything else is safe to cache forever by construction, and a transport over a
21
+ * cache says so itself rather than leaving the fact to a host's headers.
22
+ *
23
+ * `maxBytes` is what the response may weigh, required because only the caller knows what
24
+ * it asked for: a pointer, a bundle and a font carry their own ceilings. A transport
25
+ * holding a stream refuses past it before the bytes are resident; one over a file or a
26
+ * held map ignores it.
21
27
  */
22
28
  export interface FetchOptions {
29
+ maxBytes: number;
23
30
  revalidate?: boolean;
24
31
  }
25
32
  /** Load a build-output quiver via the given transport. */
@@ -6,25 +6,36 @@
6
6
  * Package-internal, and browser-safe: no static `node:` imports at any level.
7
7
  */
8
8
  import { QuiverError } from './errors.js';
9
- import { unpackFiles } from './bundle.js';
9
+ import { MAX_BUNDLE_BYTES, unpackFiles } from './bundle.js';
10
10
  import { isQuillName } from './ref.js';
11
11
  import { isCanonicalSemver, compareSemver } from './semver.js';
12
12
  import { NAME_DIGEST_LENGTH, sha256Hex } from './digest.js';
13
13
  import { MANIFEST_VERSION, POINTER_FORMAT } from './format.js';
14
14
  import { createQuiver } from './quiver.js';
15
15
  /**
16
- * Fetch, then check the bytes against the digest their name carries. That
17
- * check is what makes "content-addressed, safe to cache forever" a property
18
- * rather than a hope: it catches a corrupted CDN object, a partial sync, and a
19
- * name reused across releases. A mismatch is a transport failure (the bytes
20
- * that arrived are not the bytes asked for), so the caches that evict on error
21
- * let a retry succeed.
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.
22
33
  *
23
34
  * Where no digest primitive exists (a page served over plain http, which is
24
35
  * not a secure context) the fetch passes through unchecked.
25
36
  */
26
- async function fetchVerified(transport, path, expected) {
27
- const bytes = await transport.fetchBytes(path);
37
+ async function fetchVerified(transport, path, expected, maxBytes) {
38
+ const bytes = await transport.fetchBytes(path, { maxBytes });
28
39
  const actual = await sha256Hex(bytes);
29
40
  if (actual !== undefined && !actual.startsWith(expected)) {
30
41
  throw new QuiverError('transport_error', `Digest mismatch for "${path}": the bytes hash to ${actual.slice(0, expected.length)}, not ${expected}`);
@@ -71,7 +82,7 @@ class BuiltLoader {
71
82
  // The catalog the outer Quiver resolves against derives from this index,
72
83
  // so every ref reaching here has an entry.
73
84
  const entry = this.index.get(`${name}@${version}`);
74
- const zipBytes = await fetchVerified(this.transport, entry.bundle, digestOfName(BUNDLE_FILENAME_RE, entry.bundle, 'bundle filename'));
85
+ const zipBytes = await fetchVerified(this.transport, entry.bundle, digestOfName(BUNDLE_FILENAME_RE, entry.bundle, 'bundle filename'), MAX_BUNDLE_BYTES);
75
86
  const files = unpackFiles(zipBytes);
76
87
  const fontEntries = Object.entries(entry.fonts);
77
88
  await Promise.all(fontEntries.map(async ([path, hash]) => {
@@ -87,7 +98,7 @@ class BuiltLoader {
87
98
  fetchFont(hash) {
88
99
  let promise = this.fontCache.get(hash);
89
100
  if (!promise) {
90
- promise = fetchVerified(this.transport, `store/${hash}`, hash).catch((err) => {
101
+ promise = fetchVerified(this.transport, `store/${hash}`, hash, MAX_FONT_BYTES).catch((err) => {
91
102
  this.fontCache.delete(hash);
92
103
  throw err;
93
104
  });
@@ -128,10 +139,11 @@ function parsePointer(raw) {
128
139
  throw new QuiverError('quiver_invalid', `This quiver is built in format ${format} and this loader reads ${POINTER_FORMAT}. ` +
129
140
  `Upgrade @quillmark/quiver, or for a served client the client itself, which carries the copy that reads this.`);
130
141
  }
131
- if (typeof obj['manifest'] !== 'string' || obj['manifest'].length === 0) {
142
+ const manifest = obj['manifest'];
143
+ if (typeof manifest !== 'string' || manifest.length === 0) {
132
144
  throw new QuiverError('quiver_invalid', 'latest.json must have a non-empty string "manifest" field');
133
145
  }
134
- return obj['manifest'];
146
+ return manifest;
135
147
  }
136
148
  function parseManifest(raw) {
137
149
  let parsed;
@@ -154,58 +166,57 @@ function parseManifest(raw) {
154
166
  throw new QuiverError('quiver_invalid', `This quiver's manifest is version ${version} and this loader reads ${MANIFEST_VERSION}. ` +
155
167
  `Upgrade @quillmark/quiver, or for a served client the client itself, which carries the copy that reads this.`);
156
168
  }
157
- if (typeof obj['name'] !== 'string' || obj['name'].length === 0) {
169
+ const name = obj['name'];
170
+ if (typeof name !== 'string' || name.length === 0) {
158
171
  throw new QuiverError('quiver_invalid', 'Manifest must have a non-empty string "name" field');
159
172
  }
160
- if (obj['description'] !== undefined && typeof obj['description'] !== 'string') {
161
- throw new QuiverError('quiver_invalid', `Manifest "description" must be a string if present, got ${typeof obj['description']}`);
173
+ const description = obj['description'];
174
+ if (description !== undefined && typeof description !== 'string') {
175
+ throw new QuiverError('quiver_invalid', `Manifest "description" must be a string if present, got ${typeof description}`);
162
176
  }
163
- if (!Array.isArray(obj['quills'])) {
177
+ const entries = obj['quills'];
178
+ if (!Array.isArray(entries)) {
164
179
  throw new QuiverError('quiver_invalid', 'Manifest must have a "quills" array');
165
180
  }
166
181
  const quills = [];
167
- for (let i = 0; i < obj['quills'].length; i++) {
168
- const entry = obj['quills'][i];
182
+ for (let i = 0; i < entries.length; i++) {
183
+ const entry = entries[i];
169
184
  if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
170
185
  throw new QuiverError('quiver_invalid', `manifest.quills[${i}] must be an object`);
171
186
  }
172
187
  const e = entry;
173
188
  assertNoUnknownKeys(e, ['name', 'version', 'bundle', 'fonts'], `manifest.quills[${i}]`);
174
- if (typeof e['name'] !== 'string' || e['name'].length === 0) {
189
+ const quillName = e['name'];
190
+ if (typeof quillName !== 'string' || quillName.length === 0) {
175
191
  throw new QuiverError('quiver_invalid', `manifest.quills[${i}].name must be a non-empty string`);
176
192
  }
177
- if (!isQuillName(e['name'])) {
178
- throw new QuiverError('quiver_invalid', `manifest.quills[${i}].name "${e['name']}" is not a name a ref can spell — only [A-Za-z0-9_-] are allowed`);
193
+ 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`);
179
195
  }
180
- if (typeof e['version'] !== 'string' || !isCanonicalSemver(e['version'])) {
181
- throw new QuiverError('quiver_invalid', `manifest.quills[${i}].version must be canonical semver (x.y.z), got "${String(e['version'])}"`);
196
+ const version = e['version'];
197
+ 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)}"`);
182
199
  }
183
- if (typeof e['bundle'] !== 'string' || e['bundle'].length === 0) {
200
+ const bundle = e['bundle'];
201
+ if (typeof bundle !== 'string' || bundle.length === 0) {
184
202
  throw new QuiverError('quiver_invalid', `manifest.quills[${i}].bundle must be a non-empty string`);
185
203
  }
186
- digestOfName(BUNDLE_FILENAME_RE, e['bundle'], `manifest.quills[${i}].bundle filename`);
187
- if (typeof e['fonts'] !== 'object' || e['fonts'] === null || Array.isArray(e['fonts'])) {
204
+ digestOfName(BUNDLE_FILENAME_RE, bundle, `manifest.quills[${i}].bundle filename`);
205
+ const declared = e['fonts'];
206
+ if (typeof declared !== 'object' || declared === null || Array.isArray(declared)) {
188
207
  throw new QuiverError('quiver_invalid', `manifest.quills[${i}].fonts must be an object`);
189
208
  }
190
- const fonts = e['fonts'];
191
- for (const [k, v] of Object.entries(fonts)) {
209
+ const fonts = {};
210
+ for (const [k, v] of Object.entries(declared)) {
192
211
  if (typeof v !== 'string') {
193
212
  throw new QuiverError('quiver_invalid', `manifest.quills[${i}].fonts["${k}"] must be a string`);
194
213
  }
195
214
  validateFontHash(v, `manifest.quills[${i}].fonts["${k}"]`);
215
+ fonts[k] = v;
196
216
  }
197
- quills.push({
198
- name: e['name'],
199
- version: e['version'],
200
- bundle: e['bundle'],
201
- fonts: fonts
202
- });
217
+ quills.push({ name: quillName, version, bundle, fonts });
203
218
  }
204
- return {
205
- name: obj['name'],
206
- description: obj['description'],
207
- quills
208
- };
219
+ return { name, description, quills };
209
220
  }
210
221
  // ─── Catalog assembly ─────────────────────────────────────────────────────────
211
222
  /**
@@ -232,7 +243,10 @@ export async function loadBuiltQuiver(transport) {
232
243
  // one fetch that must revalidate.
233
244
  let pointerBytes;
234
245
  try {
235
- pointerBytes = await transport.fetchBytes('latest.json', { revalidate: true });
246
+ pointerBytes = await transport.fetchBytes('latest.json', {
247
+ revalidate: true,
248
+ maxBytes: MAX_DOCUMENT_BYTES
249
+ });
236
250
  }
237
251
  catch (err) {
238
252
  if (err instanceof QuiverError)
@@ -243,7 +257,7 @@ export async function loadBuiltQuiver(transport) {
243
257
  const manifestDigest = digestOfName(MANIFEST_FILENAME_RE, manifestFileName, 'Pointer manifest filename');
244
258
  let manifestBytes;
245
259
  try {
246
- manifestBytes = await fetchVerified(transport, manifestFileName, manifestDigest);
260
+ manifestBytes = await fetchVerified(transport, manifestFileName, manifestDigest, MAX_DOCUMENT_BYTES);
247
261
  }
248
262
  catch (err) {
249
263
  if (err instanceof QuiverError)
package/dist/bundle.d.ts CHANGED
@@ -1,6 +1,22 @@
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;
4
20
  /**
5
21
  * Pack a flat file map into a deterministic zip.
6
22
  * Keys are sorted before zipping so insertion order doesn't affect output.
@@ -10,6 +26,8 @@ export declare function packFiles(files: Record<string, Uint8Array>): Uint8Array
10
26
  /**
11
27
  * Unpack a zip into a flat file map.
12
28
  * Returns { path: Uint8Array } for every file entry in the archive.
13
- * Throws `quiver_invalid` where the budget is spent, before any entry inflates.
29
+ * Throws `quiver_invalid` where the budget is spent. fflate filters and inflates one
30
+ * entry per pass, so the entries the budget already took are resident when it throws —
31
+ * bounded, being what fit inside it.
14
32
  */
15
33
  export declare function unpackFiles(data: Uint8Array): Record<string, Uint8Array>;
package/dist/bundle.js CHANGED
@@ -25,8 +25,11 @@ const ZIP_EPOCH = new Date(1980, 0, 1, 0, 0, 0, 0);
25
25
  *
26
26
  * Fonts are not in here: `build` writes them to the store and the manifest 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.
28
31
  */
29
- const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
32
+ export const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
30
33
  const MAX_ENTRY_BYTES = 16 * 1024 * 1024;
31
34
  const MAX_BUNDLE_ENTRIES = 2048;
32
35
  /**
@@ -73,19 +76,16 @@ export function packFiles(files) {
73
76
  /**
74
77
  * Unpack a zip into a flat file map.
75
78
  * Returns { path: Uint8Array } for every file entry in the archive.
76
- * Throws `quiver_invalid` where the budget is spent, before any entry inflates.
79
+ * Throws `quiver_invalid` where the budget is spent. fflate filters and inflates one
80
+ * entry per pass, so the entries the budget already took are resident when it throws —
81
+ * bounded, being what fit inside it.
77
82
  */
78
83
  export function unpackFiles(data) {
79
84
  const spend = bundleBudget();
80
- const raw = unzipSync(data, {
85
+ return unzipSync(data, {
81
86
  filter: ({ name, originalSize }) => {
82
87
  spend(name, originalSize);
83
88
  return true;
84
89
  }
85
90
  });
86
- const result = {};
87
- for (const [key, value] of Object.entries(raw)) {
88
- result[key] = value;
89
- }
90
- return result;
91
91
  }
@@ -45,13 +45,11 @@ export function parseQuiverYaml(raw) {
45
45
  if (!isQuillName(name)) {
46
46
  throw new QuiverError('quiver_invalid', `Quiver.yaml: "name" value "${name}" contains invalid characters — only [A-Za-z0-9_-] are allowed`);
47
47
  }
48
- if ('description' in doc && doc['description'] !== undefined) {
48
+ const meta = { name };
49
+ if (doc['description'] !== undefined) {
49
50
  if (typeof doc['description'] !== 'string') {
50
51
  throw new QuiverError('quiver_invalid', `Quiver.yaml: "description" must be a string if present, got ${typeof doc['description']}`);
51
52
  }
52
- }
53
- const meta = { name };
54
- if (typeof doc['description'] === 'string') {
55
53
  meta.description = doc['description'];
56
54
  }
57
55
  return meta;
package/dist/quiver.js CHANGED
@@ -10,7 +10,7 @@
10
10
  import { QuiverError } from './errors.js';
11
11
  import { init } from '@quillmark/wasm';
12
12
  import { parseQuillRef } from './ref.js';
13
- import { matchesSemverSelector, chooseHighestVersion } from './semver.js';
13
+ import { matchesSemverSelector } from './semver.js';
14
14
  /**
15
15
  * The private constructor's only door, closed over by the static block below.
16
16
  * Exported as `createQuiver` for this package's loaders and reachable from no
@@ -121,17 +121,16 @@ export class Quiver {
121
121
  * - `quill_not_found` if no version matches
122
122
  */
123
123
  resolve(ref) {
124
- const parsed = parseQuillRef(ref);
125
- const versions = this.#catalog.get(parsed.name);
126
- if (versions && versions.length > 0) {
127
- const candidates = parsed.selector === undefined
128
- ? [...versions]
129
- : versions.filter((v) => matchesSemverSelector(v, parsed.selector));
130
- if (candidates.length > 0) {
131
- // chooseHighestVersion returns null only for empty arrays; candidates is non-empty.
132
- const winner = chooseHighestVersion(candidates);
133
- return `${parsed.name}@${winner}`;
134
- }
124
+ const { name, selector } = parseQuillRef(ref);
125
+ const versions = this.#catalog.get(name);
126
+ if (versions !== undefined) {
127
+ // Every loader materializes a catalog sorted descending, so the first match is
128
+ // the highest.
129
+ const winner = selector === undefined
130
+ ? versions[0]
131
+ : versions.find((v) => matchesSemverSelector(v, selector));
132
+ if (winner !== undefined)
133
+ return `${name}@${winner}`;
135
134
  }
136
135
  throw new QuiverError('quill_not_found', `No quill found for ref "${ref}" in quiver "${this.name}".`, { ref, quiverName: this.name });
137
136
  }
package/dist/ref.d.ts CHANGED
@@ -3,8 +3,6 @@ export interface ParsedQuillRef {
3
3
  name: string;
4
4
  /** Undefined selects the highest version present. */
5
5
  selector?: string;
6
- /** Selector part count: 1 = `x`, 2 = `x.y`, 3 = `x.y.z` (exact). */
7
- selectorDepth?: 1 | 2 | 3;
8
6
  }
9
7
  /**
10
8
  * Whether `name` is a name a ref can spell. A quiver's own name and every catalog
package/dist/ref.js CHANGED
@@ -22,19 +22,11 @@ export function parseQuillRef(ref) {
22
22
  if (atIndex === 0) {
23
23
  throw new QuiverError('invalid_ref', `Invalid ref: missing name in "${ref}"`, { ref });
24
24
  }
25
- if (atIndex === -1) {
26
- const name = ref;
27
- if (!NAME_RE.test(name)) {
28
- throw new QuiverError('invalid_ref', `Invalid ref: name "${name}" contains invalid characters`, { ref });
29
- }
30
- return { name };
31
- }
32
- const name = ref.slice(0, atIndex);
33
- const selector = ref.slice(atIndex + 1);
34
- if (!name) {
35
- throw new QuiverError('invalid_ref', `Invalid ref: missing name in "${ref}"`, { ref });
36
- }
37
- if (!selector) {
25
+ // Split once: the charset the name is held to is one rule, and a ref with a selector
26
+ // is under it exactly as a bare one is.
27
+ const name = atIndex === -1 ? ref : ref.slice(0, atIndex);
28
+ const selector = atIndex === -1 ? undefined : ref.slice(atIndex + 1);
29
+ if (selector === '') {
38
30
  throw new QuiverError('invalid_ref', `Invalid ref: missing selector after "@" in "${ref}"`, {
39
31
  ref
40
32
  });
@@ -42,10 +34,11 @@ export function parseQuillRef(ref) {
42
34
  if (!NAME_RE.test(name)) {
43
35
  throw new QuiverError('invalid_ref', `Invalid ref: name "${name}" contains invalid characters`, { ref });
44
36
  }
37
+ if (selector === undefined) {
38
+ return { name };
39
+ }
45
40
  if (!SELECTOR_RE.test(selector)) {
46
41
  throw new QuiverError('invalid_ref', `Invalid ref: selector "${selector}" is not a valid semver selector (only x, x.y, x.y.z with digits allowed)`, { ref });
47
42
  }
48
- const parts = selector.split('.');
49
- const depth = parts.length;
50
- return { name, selector, selectorDepth: depth };
43
+ return { name, selector };
51
44
  }
package/dist/semver.d.ts CHANGED
@@ -8,9 +8,8 @@ export declare function isCanonicalSemver(version: string): boolean;
8
8
  * prefix comparison over an already-valid selector.
9
9
  */
10
10
  export declare function matchesSemverSelector(version: string, selector: string): boolean;
11
- /** Compares two canonical semver strings. Returns <0, 0, or >0. */
11
+ /** Compares two canonical semver strings. Returns <0, 0, or >0. Three positions,
12
+ * because `isCanonicalSemver` gates every entry point that reaches here. */
12
13
  export declare function compareSemver(a: string, b: string): number;
13
14
  /** Returns true for a canonical version below `MIN_PUBLISHED_VERSION`. */
14
15
  export declare function isDraft(version: string): boolean;
15
- /** Returns the highest version string, or null if empty. */
16
- export declare function chooseHighestVersion(versions: string[]): string | null;
package/dist/semver.js CHANGED
@@ -10,8 +10,6 @@ export function isCanonicalSemver(version) {
10
10
  * prefix comparison over an already-valid selector.
11
11
  */
12
12
  export function matchesSemverSelector(version, selector) {
13
- if (selector === version)
14
- return true;
15
13
  const selectorParts = selector.split('.');
16
14
  const versionParts = version.split('.');
17
15
  if (selectorParts.length > versionParts.length)
@@ -22,16 +20,14 @@ export function matchesSemverSelector(version, selector) {
22
20
  }
23
21
  return true;
24
22
  }
25
- /** Compares two canonical semver strings. Returns <0, 0, or >0. */
23
+ /** Compares two canonical semver strings. Returns <0, 0, or >0. Three positions,
24
+ * because `isCanonicalSemver` gates every entry point that reaches here. */
26
25
  export function compareSemver(a, b) {
27
26
  const partsA = a.split('.').map(Number);
28
27
  const partsB = b.split('.').map(Number);
29
- const len = Math.max(partsA.length, partsB.length);
30
- for (let i = 0; i < len; i++) {
31
- const numA = partsA[i] ?? 0;
32
- const numB = partsB[i] ?? 0;
33
- if (numA !== numB)
34
- return numA - numB;
28
+ for (let i = 0; i < 3; i++) {
29
+ if (partsA[i] !== partsB[i])
30
+ return partsA[i] - partsB[i];
35
31
  }
36
32
  return 0;
37
33
  }
@@ -39,11 +35,3 @@ export function compareSemver(a, b) {
39
35
  export function isDraft(version) {
40
36
  return compareSemver(version, MIN_PUBLISHED_VERSION) < 0;
41
37
  }
42
- /** Returns the highest version string, or null if empty. */
43
- export function chooseHighestVersion(versions) {
44
- if (versions.length === 0)
45
- return null;
46
- const copy = [...versions];
47
- copy.sort((a, b) => compareSemver(b, a));
48
- return copy[0] ?? null;
49
- }
@@ -11,8 +11,8 @@ import type { QuiverLoader } from './quiver.js';
11
11
  * to build a catalog of quill names → sorted versions (descending).
12
12
  *
13
13
  * Throws:
14
- * - `quiver_invalid` if Quiver.yaml is missing/invalid, a quill or version directory
15
- * is a symlink, a quill dir holding versions is named outside the ref charset, a
14
+ * - `quiver_invalid` if Quiver.yaml is invalid, any rung it opens or reads is a
15
+ * symlink, a quill dir holding versions is named outside the ref charset, a
16
16
  * version dir name is non-canonical, or a version dir is missing its Quill.yaml
17
17
  * sentinel.
18
18
  * - `transport_error` for I/O failures (permissions, etc.).
@@ -2,7 +2,7 @@
2
2
  * Internal filesystem scanner for the Source Quiver layout. Statically imports
3
3
  * `node:fs`, so it is reachable from the Node entry alone.
4
4
  */
5
- import { readdir, readFile, lstat, stat } from 'node:fs/promises';
5
+ import { readdir, readFile, lstat } from 'node:fs/promises';
6
6
  import { join, relative, sep } from 'node:path';
7
7
  import { QuiverError } from './errors.js';
8
8
  import { parseQuiverYaml } from './quiver-yaml.js';
@@ -27,8 +27,8 @@ function refuseSymlink(st, path) {
27
27
  * to build a catalog of quill names → sorted versions (descending).
28
28
  *
29
29
  * Throws:
30
- * - `quiver_invalid` if Quiver.yaml is missing/invalid, a quill or version directory
31
- * is a symlink, a quill dir holding versions is named outside the ref charset, a
30
+ * - `quiver_invalid` if Quiver.yaml is invalid, any rung it opens or reads is a
31
+ * symlink, a quill dir holding versions is named outside the ref charset, a
32
32
  * version dir name is non-canonical, or a version dir is missing its Quill.yaml
33
33
  * sentinel.
34
34
  * - `transport_error` for I/O failures (permissions, etc.).
@@ -39,9 +39,12 @@ export async function scanSourceQuiver(rootDir) {
39
39
  const quiverYamlPath = join(rootDir, 'Quiver.yaml');
40
40
  let raw;
41
41
  try {
42
+ refuseSymlink(await lstat(quiverYamlPath), quiverYamlPath);
42
43
  raw = await readFile(quiverYamlPath);
43
44
  }
44
45
  catch (err) {
46
+ if (err instanceof QuiverError)
47
+ throw err;
45
48
  // ENOENT → transport_error: a missing Quiver.yaml means the path itself
46
49
  // does not point to a quiver — this is a missing-path condition, not a
47
50
  // structural violation of a quiver that exists.
@@ -104,9 +107,11 @@ export async function scanSourceQuiver(rootDir) {
104
107
  }
105
108
  const quillYamlPath = join(versionPath, 'Quill.yaml');
106
109
  try {
107
- await stat(quillYamlPath);
110
+ refuseSymlink(await lstat(quillYamlPath), quillYamlPath);
108
111
  }
109
112
  catch (err) {
113
+ if (err instanceof QuiverError)
114
+ throw err;
110
115
  const code = err.code;
111
116
  if (code === 'ENOENT') {
112
117
  throw new QuiverError('quiver_invalid', `Quill "${quillName}@${versionDir}" is missing required "Quill.yaml"`, { quiverName: meta.name, version: versionDir });
@@ -14,8 +14,8 @@ export class FsBuiltTransport {
14
14
  constructor(rootDir) {
15
15
  this.rootDir = rootDir;
16
16
  }
17
- // No cache sits between this and the bytes, so `FetchOptions` has nothing to
18
- // act on here.
17
+ // No cache sits between this and the bytes, and a file is read at the size it
18
+ // is, so `FetchOptions` has nothing to act on here.
19
19
  async fetchBytes(relativePath) {
20
20
  if (isAbsolute(relativePath)) {
21
21
  throw new QuiverError('transport_error', `FsBuiltTransport: absolute paths are not allowed: "${relativePath}"`);
@@ -8,5 +8,5 @@ import type { BuiltTransport, FetchOptions } from '../built-loader.js';
8
8
  export declare class HttpTransport implements BuiltTransport {
9
9
  private readonly baseUrl;
10
10
  constructor(baseUrl: string);
11
- fetchBytes(relativePath: string, opts?: FetchOptions): Promise<Uint8Array>;
11
+ fetchBytes(relativePath: string, opts: FetchOptions): Promise<Uint8Array>;
12
12
  }
@@ -5,6 +5,36 @@
5
5
  * Uses globalThis.fetch — no node: imports at any level.
6
6
  */
7
7
  import { QuiverError } from '../errors.js';
8
+ /** Over the ceiling is `quiver_invalid`, not `transport_error`: a retry fetches the same
9
+ * response, so there is nothing for an evicting cache to fix. */
10
+ function oversized(url, max, says) {
11
+ return new QuiverError('quiver_invalid', `"${url}" ${says} the ${max} bytes a fetch may read`);
12
+ }
13
+ /** The body chunk by chunk, refused past the ceiling. Cancelling the reader terminates
14
+ * the fetch, so nothing more of a refused response is pulled off the socket. */
15
+ async function readCapped(body, url, max) {
16
+ const reader = body.getReader();
17
+ const chunks = [];
18
+ let total = 0;
19
+ for (;;) {
20
+ const { done, value } = await reader.read();
21
+ if (done)
22
+ break;
23
+ total += value.byteLength;
24
+ if (total > max) {
25
+ await reader.cancel();
26
+ throw oversized(url, max, 'is over');
27
+ }
28
+ chunks.push(value);
29
+ }
30
+ const bytes = new Uint8Array(total);
31
+ let at = 0;
32
+ for (const chunk of chunks) {
33
+ bytes.set(chunk, at);
34
+ at += chunk.byteLength;
35
+ }
36
+ return bytes;
37
+ }
8
38
  export class HttpTransport {
9
39
  baseUrl;
10
40
  constructor(baseUrl) {
@@ -15,11 +45,13 @@ export class HttpTransport {
15
45
  const url = `${this.baseUrl}${cleanPath}`;
16
46
  let response;
17
47
  try {
18
- // `no-cache` revalidates with the origin rather than skipping the cache:
19
- // a 304 still serves from disk. Only the pointer asks for it, and only
20
- // the browser-cache layer of the stale-pointer failure is a client's to
21
- // fix; a stale edge is answered by that host's cache headers.
22
- response = await globalThis.fetch(url, opts?.revalidate ? { cache: 'no-cache' } : undefined);
48
+ // `no-cache` revalidates with the origin rather than skipping the cache: a 304
49
+ // still serves from disk. `force-cache` takes a cached response whatever its
50
+ // age, which a digest-carrying name is entitled to; a stale one fails
51
+ // verification upstream as `transport_error`, which an evicting cache clears.
52
+ response = await globalThis.fetch(url, {
53
+ cache: opts.revalidate ? 'no-cache' : 'force-cache'
54
+ });
23
55
  }
24
56
  catch (err) {
25
57
  throw new QuiverError('transport_error', `Network error fetching "${url}": ${err.message}`, { cause: err });
@@ -27,7 +59,29 @@ export class HttpTransport {
27
59
  if (!response.ok) {
28
60
  throw new QuiverError('transport_error', `HTTP ${response.status} fetching "${url}"`);
29
61
  }
30
- const buffer = await response.arrayBuffer();
31
- return new Uint8Array(buffer);
62
+ // A fast reject; the running total below is what holds the ceiling. `Content-Length`
63
+ // is the encoded length, so under `Content-Encoding` it under-reports; a missing one is
64
+ // 0 and an unparseable one NaN, neither over the ceiling, so both accept early.
65
+ const declared = Number(response.headers.get('content-length'));
66
+ if (declared > opts.maxBytes) {
67
+ throw oversized(url, opts.maxBytes, `states ${declared} bytes, over`);
68
+ }
69
+ try {
70
+ // A host that hands back no stream is measured after the fact rather than
71
+ // refused: the degraded check still bounds what reaches the unpack budget,
72
+ // and refusing outright would cost hosts that work today.
73
+ if (response.body === null) {
74
+ const buffer = await response.arrayBuffer();
75
+ if (buffer.byteLength > opts.maxBytes)
76
+ throw oversized(url, opts.maxBytes, 'is over');
77
+ return new Uint8Array(buffer);
78
+ }
79
+ return await readCapped(response.body, url, opts.maxBytes);
80
+ }
81
+ catch (err) {
82
+ if (err instanceof QuiverError)
83
+ throw err;
84
+ throw new QuiverError('transport_error', `Network error reading "${url}": ${err.message}`, { cause: err });
85
+ }
32
86
  }
33
87
  }
@@ -10,6 +10,7 @@ export declare class MemoryTransport implements BuiltTransport {
10
10
  private readonly fallback?;
11
11
  private readonly files;
12
12
  constructor(files: ReadonlyMap<string, Uint8Array>, fallback?: BuiltTransport | undefined);
13
- /** The map answers first. `FetchOptions` reaches only the fallback: a held byte has no cache to revalidate against. */
14
- fetchBytes(relativePath: string, opts?: FetchOptions): Promise<Uint8Array>;
13
+ /** The map answers first. `FetchOptions` reaches only the fallback: a held byte has no
14
+ * cache to revalidate against and no arrival to refuse. */
15
+ fetchBytes(relativePath: string, opts: FetchOptions): Promise<Uint8Array>;
15
16
  }
@@ -21,7 +21,8 @@ export class MemoryTransport {
21
21
  this.fallback = fallback;
22
22
  this.files = normalizeKeys(files);
23
23
  }
24
- /** The map answers first. `FetchOptions` reaches only the fallback: a held byte has no cache to revalidate against. */
24
+ /** The map answers first. `FetchOptions` reaches only the fallback: a held byte has no
25
+ * cache to revalidate against and no arrival to refuse. */
25
26
  async fetchBytes(relativePath, opts) {
26
27
  const bytes = this.files.get(relativePath);
27
28
  if (bytes !== undefined)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quillmark/quiver",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "A quiver of Quillmark quills: load one, pack one, hand out quills.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -42,7 +42,7 @@
42
42
  "NOTICE"
43
43
  ],
44
44
  "peerDependencies": {
45
- "@quillmark/wasm": ">=0.109.0-0"
45
+ "@quillmark/wasm": ">=0.113.0-0"
46
46
  },
47
47
  "dependencies": {
48
48
  "fflate": "^0.8.2",