@quillmark/quiver 0.29.0 → 0.30.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/MIGRATION.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  Upgrade notes per range. The [CHANGELOG](CHANGELOG.md) is the full record; this page is the subset that breaks a working consumer, ordered by how late you find out.
4
4
 
5
+ ## 0.29 → 0.30
6
+
7
+ ### Rebuild every artifact, and move quiver and quillkit together
8
+
9
+ A built quiver is `quiver.json` beside one bundle per quill and a `fonts/` directory. The hashed manifest and `store/` are gone, and `latest.json` carries only `format`, now 2. An old artifact under 0.30 is a `transport_error` on `quiver.json`; a 0.30 artifact under an older reader is refused with the upgrade named. Re-run `build`; nothing in the source layout changes.
10
+
11
+ `quillkit`'s client carries its own copy of this package's reader and packs through the collection's, so a collection upgrades `@quillmark/quiver` in the same change as `quillkit`. `quillkit site` and `quillkit studio` refuse a pack with no `quiver.json`, naming the upgrade.
12
+
13
+ A host rule naming `quiver/latest.json` now names `quiver/quiver.json`.
14
+
15
+ ### Runtime, not build time
16
+
17
+ **`Quiver.fromBuiltUrl(url, { seed })` takes no `seed`.** TypeScript refuses the second argument; plain JavaScript ignores it, so a seeded call fetches what it used to hold. Hold the whole artifact and call `Quiver.fromBuiltFiles(files)`, or fetch it.
18
+
19
+ **Fetched bytes are not checked against their names**, and no fetch carries a byte ceiling; a bundle's unpack budget is unchanged. A bundle that does not open as a zip and a font that does not open as a font are refused, and `build` refuses a font file of the second kind where it packs it. Serve the artifact over `https`, which is what answers a corrupted byte.
20
+
5
21
  ## 0.16 → 0.19
6
22
 
7
23
  ### Runtime, not build time
package/README.md CHANGED
@@ -99,7 +99,7 @@ const quiver = await fromBuiltDir('./static/quills/my-quiver');
99
99
 
100
100
  ## Server-side runtime (no filesystem)
101
101
 
102
- A serverless function's packed artifact is not on a path the invocation can read, so `fromBuiltDir` cannot see it. Hand over the bytes instead, keyed by artifact-relative path as `build` writes them (`latest.json`, `manifest.<digest>.json`, `<name>@<x.y.z>.<digest>.zip`, `store/<hash>`):
102
+ A serverless function's packed artifact is not on a path the invocation can read, so `fromBuiltDir` cannot see it. Hand over the bytes instead, keyed by artifact-relative path as `build` writes them (`quiver.json`, `<name>@<x.y.z>.<digest>.zip`, `fonts/<hash>`):
103
103
 
104
104
  ```ts
105
105
  import { Quiver } from '@quillmark/quiver';
@@ -109,30 +109,17 @@ const quiver = await Quiver.fromBuiltFiles(artifactFiles); // Map<string, Uint8A
109
109
 
110
110
  Nothing is fetched, so nothing self-fetches over your own load balancer. The map must carry the whole artifact; a path it lacks is a `transport_error` naming that path.
111
111
 
112
- Where inlining the bundles and fonts is not practical, hold the two small documents and let the host serve the rest:
112
+ ## The `quiver.json` index
113
113
 
114
- ```ts
115
- const quiver = await Quiver.fromBuiltUrl('/quills/my-quiver/', {
116
- seed: new Map([
117
- ['latest.json', pointerBytes],
118
- [manifestName, manifestBytes]
119
- ])
120
- });
121
- ```
122
-
123
- The seed answers first and the URL serves what it does not carry. Seeding `latest.json` also settles which catalog the process reads at deploy time rather than at cache-revalidation time. Seeded bytes are checked against the digest in their name exactly as fetched bytes are.
124
-
125
- ## The `latest.json` pointer
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), 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.
114
+ `Quiver.fromBuiltUrl(url)` first fetches `<url>/quiver.json`: the format and the catalog, naming each quill's bundle and fonts. Every other name carries the digest of its own bytes, so a changed file is a changed name and is safe to cache forever; `quiver.json` is not, and a cache layer serving a stale one silently pins 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`, read once more with `reload` if what the cache answered fails. 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
115
 
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.
116
+ A `getQuill` reads one bundle and the fonts it names, nothing of another quill. Fonts are stored once by hash, so a release that edits a template re-downloads that quill's bundle and not its fonts. A tab still holding the previous release's `quiver.json` rereads it once when a read fails.
130
117
 
131
118
  ## What a quiver is trusted to be
132
119
 
133
120
  **A quill is a template the backend executes, so loading a quiver runs its author's code.** Point one at a source you would take a dependency from — an npm package or a git tag, pinned like any other — because nothing here sandboxes a quill, and a collection assembled from anywhere else is a decision to make on purpose.
134
121
 
135
- Content addressing is an integrity check and not a provenance one: the digest in each name catches a corrupted object, a partial sync and a name reused across releases, and says nothing about who packed the bytes. Where the primitive behind it is absent the page is no secure context either, so the pointer, the manifest, the digests and the client itself all arrived over the same unauthenticated channel — which is the whole of why a built quiver is served over `https`.
122
+ Nothing checks fetched bytes against their names: the digests are there for caching, and what answers a corrupted or substituted byte is the channel. `quiver.json`, the bundles and the client itself arrive over the same one, which is the whole of why a built quiver is served over `https`.
136
123
 
137
124
  ## Error handling
138
125
 
package/dist/build.d.ts CHANGED
@@ -9,22 +9,22 @@
9
9
  export interface BuildOptions {
10
10
  /**
11
11
  * Pack versions below `MIN_PUBLISHED_VERSION` too. Off by default: the
12
- * artifact is a deployment, and the draft space is not part of one. An
13
- * author's own viewer turns it on, which is the only caller that should.
12
+ * artifact is a deployment, and the draft space is not part of one. A
13
+ * viewer of the collection as it stands turns it on.
14
14
  */
15
15
  drafts?: boolean;
16
16
  }
17
17
  /**
18
18
  * Reads a Source Quiver, validates it, and writes the build output to outDir.
19
19
  *
20
- * Output layout. Every name but the pointer carries the SHA-256 of what it
21
- * names, which is what the loader checks on fetch:
20
+ * Output layout. Every name but the two JSON documents carries the SHA-256 of what it
21
+ * names, which is what makes it safe to cache forever:
22
22
  * outDir/
23
- * latest.json # the format, and a stable pointer to the manifest
24
- * manifest.<sha256:32>.json # hashed manifest
23
+ * quiver.json # the format and the catalog
24
+ * latest.json # the format alone, for a reader of format 1
25
25
  * <name>@<version>.<sha256:32>.zip # one bundle per quill
26
- * store/
27
- * <sha256> # dehydrated font bytes (full hash, no ext)
26
+ * fonts/
27
+ * <sha256> # dehydrated font bytes (full hash, no ext)
28
28
  *
29
29
  * A generation is assembled beside outDir and moved in whole, so a reader
30
30
  * fetching mid-build reads the previous one rather than a torn tree, and a
@@ -32,7 +32,7 @@ export interface BuildOptions {
32
32
  *
33
33
  * Versions below `MIN_PUBLISHED_VERSION` are drafts and are left out unless
34
34
  * `options.drafts` asks for them; a quill with nothing above the floor is
35
- * absent from the manifest entirely.
35
+ * absent from `quiver.json` entirely.
36
36
  *
37
37
  * Throws:
38
38
  * - `quiver_invalid` on source validation failures (propagated from scanner)
package/dist/build.js CHANGED
@@ -7,9 +7,11 @@
7
7
  */
8
8
  import { QuiverError } from './errors.js';
9
9
  import { packFiles } from './bundle.js';
10
- import { NAME_DIGEST_LENGTH } from './digest.js';
11
- import { MANIFEST_VERSION, POINTER_FORMAT } from './format.js';
10
+ import { FORMAT, INDEX, LEGACY_POINTER } from './format.js';
11
+ import { isFont } from './signature.js';
12
12
  import { isDraft } from './semver.js';
13
+ /** Hex chars of SHA-256 in a bundle's name: enough that a changed bundle never reuses one. */
14
+ const NAME_DIGEST_LENGTH = 32;
13
15
  /** Font file extensions recognised by the builder (case-insensitive). */
14
16
  const FONT_EXT = /\.(ttf|otf|woff|woff2)$/i;
15
17
  /** The two trees a build owns beside its output: where a generation is assembled, and
@@ -52,14 +54,14 @@ function assertSafeOutDir(path, sourceDir, outDir) {
52
54
  /**
53
55
  * Reads a Source Quiver, validates it, and writes the build output to outDir.
54
56
  *
55
- * Output layout. Every name but the pointer carries the SHA-256 of what it
56
- * names, which is what the loader checks on fetch:
57
+ * Output layout. Every name but the two JSON documents carries the SHA-256 of what it
58
+ * names, which is what makes it safe to cache forever:
57
59
  * outDir/
58
- * latest.json # the format, and a stable pointer to the manifest
59
- * manifest.<sha256:32>.json # hashed manifest
60
+ * quiver.json # the format and the catalog
61
+ * latest.json # the format alone, for a reader of format 1
60
62
  * <name>@<version>.<sha256:32>.zip # one bundle per quill
61
- * store/
62
- * <sha256> # dehydrated font bytes (full hash, no ext)
63
+ * fonts/
64
+ * <sha256> # dehydrated font bytes (full hash, no ext)
63
65
  *
64
66
  * A generation is assembled beside outDir and moved in whole, so a reader
65
67
  * fetching mid-build reads the previous one rather than a torn tree, and a
@@ -67,7 +69,7 @@ function assertSafeOutDir(path, sourceDir, outDir) {
67
69
  *
68
70
  * Versions below `MIN_PUBLISHED_VERSION` are drafts and are left out unless
69
71
  * `options.drafts` asks for them; a quill with nothing above the floor is
70
- * absent from the manifest entirely.
72
+ * absent from `quiver.json` entirely.
71
73
  *
72
74
  * Throws:
73
75
  * - `quiver_invalid` on source validation failures (propagated from scanner)
@@ -111,14 +113,12 @@ export async function buildQuiver(sourceDir, outDir, options = {}) {
111
113
  async function packGeneration() {
112
114
  try {
113
115
  await rm(stage, { recursive: true, force: true });
114
- await mkdir(join(stage, 'store'), { recursive: true });
116
+ await mkdir(join(stage, 'fonts'), { recursive: true });
115
117
  }
116
118
  catch (err) {
117
119
  throw new QuiverError('transport_error', `Failed to prepare output directory "${outDir}": ${err.message}`, { cause: err });
118
120
  }
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.
121
+ const quills = [];
122
122
  const stored = new Set();
123
123
  for (const [quillName, versions] of catalog) {
124
124
  for (const version of versions) {
@@ -133,17 +133,22 @@ export async function buildQuiver(sourceDir, outDir, options = {}) {
133
133
  contentRecord[rel] = bytes;
134
134
  continue;
135
135
  }
136
- // Full width: the store is keyed by hash, so two distinct fonts
137
- // sharing a prefix would merge into one entry.
136
+ // The loader refuses what does not open as a font, so the build refuses it
137
+ // first, where the author it names can do something about it.
138
+ if (!isFont(bytes, rel)) {
139
+ throw new QuiverError('quiver_invalid', `Quill "${quillName}@${version}": "${rel}" does not open as the font its extension names`, { quiverName: meta.name, version });
140
+ }
141
+ // Full width: fonts are keyed by hash, so two distinct fonts sharing a
142
+ // prefix would merge into one file.
138
143
  const hash = createHash('sha256').update(bytes).digest('hex');
139
144
  fonts[rel] = hash;
140
145
  if (stored.has(hash))
141
146
  continue;
142
147
  try {
143
- await writeFile(join(stage, 'store', hash), bytes);
148
+ await writeFile(join(stage, 'fonts', hash), bytes);
144
149
  }
145
150
  catch (err) {
146
- throw new QuiverError('transport_error', `Failed to write font store entry "${join(outDir, 'store', hash)}": ${err.message}`, { cause: err });
151
+ throw new QuiverError('transport_error', `Failed to write font "${join(outDir, 'fonts', hash)}": ${err.message}`, { cause: err });
147
152
  }
148
153
  stored.add(hash);
149
154
  }
@@ -168,35 +173,23 @@ export async function buildQuiver(sourceDir, outDir, options = {}) {
168
173
  catch (err) {
169
174
  throw new QuiverError('transport_error', `Failed to write bundle "${join(outDir, bundleName)}": ${err.message}`, { cause: err });
170
175
  }
171
- manifestQuills.push({ name: quillName, version, bundle: bundleName, fonts });
176
+ quills.push({ name: quillName, version, bundle: bundleName, fonts });
172
177
  }
173
178
  }
174
- const manifest = {
175
- version: MANIFEST_VERSION,
179
+ // The format is stamped here and read first, so a client older than the tree says so
180
+ // rather than misreading it.
181
+ const index = {
182
+ format: FORMAT,
176
183
  name: meta.name,
177
184
  ...(meta.description === undefined ? {} : { description: meta.description }),
178
- quills: manifestQuills
185
+ quills
179
186
  };
180
- const manifestJson = JSON.stringify(manifest, null, 2);
181
- const manifestHash = createHash('sha256')
182
- .update(manifestJson)
183
- .digest('hex')
184
- .slice(0, NAME_DIGEST_LENGTH);
185
- const manifestFileName = `manifest.${manifestHash}.json`;
186
- try {
187
- await writeFile(join(stage, manifestFileName), manifestJson, 'utf-8');
188
- }
189
- catch (err) {
190
- throw new QuiverError('transport_error', `Failed to write manifest "${join(outDir, manifestFileName)}": ${err.message}`, { cause: err });
191
- }
192
- // The format is stamped here and read first, so a client older than the tree
193
- // says so rather than misreading it.
194
- const pointer = { format: POINTER_FORMAT, manifest: manifestFileName };
195
187
  try {
196
- await writeFile(join(stage, 'latest.json'), JSON.stringify(pointer), 'utf-8');
188
+ await writeFile(join(stage, INDEX), JSON.stringify(index, null, 2), 'utf-8');
189
+ await writeFile(join(stage, LEGACY_POINTER), JSON.stringify({ format: FORMAT }), 'utf-8');
197
190
  }
198
191
  catch (err) {
199
- throw new QuiverError('transport_error', `Failed to write pointer "${join(outDir, 'latest.json')}": ${err.message}`, { cause: err });
192
+ throw new QuiverError('transport_error', `Failed to write "${join(outDir, INDEX)}" or its legacy pointer: ${err.message}`, { cause: err });
200
193
  }
201
194
  // `prev` is cleared first: a rename onto a non-empty directory fails, and the
202
195
  // tree left by an interrupted run is one.
@@ -1,33 +1,19 @@
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 type { Quiver } from './quiver.js';
9
8
  /**
10
- * Transport abstraction: fetch raw bytes by relative path within the packed
11
- * artifact. Implemented by HttpTransport (browser + Node) and FsBuiltTransport
12
- * (Node).
9
+ * Bytes at an artifact-relative path, under a `fetch` cache mode. `no-cache` is
10
+ * `quiver.json`'s, the one name read that carries no digest; every other name is read
11
+ * `force-cache`, and `reload` once a cached answer failed, since a digest in a name
12
+ * does not stop a host's page from being cached under it.
13
13
  */
14
- export interface BuiltTransport {
15
- fetchBytes(relativePath: string, opts: FetchOptions): Promise<Uint8Array>;
16
- }
17
- /**
18
- * `revalidate` marks the one request that must not be answered from a cache:
19
- * `latest.json`, the only name in the artifact that is not content-addressed.
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.
27
- */
28
- export interface FetchOptions {
29
- maxBytes: number;
30
- revalidate?: boolean;
31
- }
32
- /** Load a build-output quiver via the given transport. */
33
- export declare function loadBuiltQuiver(transport: BuiltTransport): Promise<Quiver>;
14
+ export type ReadMode = 'no-cache' | 'force-cache' | 'reload';
15
+ export type ArtifactReader = (path: string, mode: ReadMode) => Promise<Uint8Array>;
16
+ export declare function httpReader(base: string): ArtifactReader;
17
+ /** Over bytes the caller holds; a leading `./` or `/` on a key is dropped. */
18
+ export declare function filesReader(files: ReadonlyMap<string, Uint8Array>): ArtifactReader;
19
+ export declare function loadBuiltQuiver(read: ArtifactReader): Promise<Quiver>;