gm-pantry 0.1.0 → 0.2.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
@@ -29,13 +29,15 @@ A repo is a directory. Its entry point is `repo.json`, and the URL of that file
29
29
  download; a mismatch discards the file.
30
30
  - File urls may be relative, so a mirror of the directory is a working repo.
31
31
  - Unknown fields are ignored; a malformed game or version is skipped and the rest of the repo still loads.
32
+ - A big repo lists content-addressed `shards` (files of at most 1 000 games) instead of inlining its
33
+ games; a bad or tampered shard is skipped and the rest still loads.
32
34
  - Nothing lists or indexes repos. A private repo is an unguessable URL.
33
35
 
34
36
  ## CLI
35
37
 
36
38
  ```
37
39
  npx gm-pantry init <dir> [--name NAME]
38
- npx gm-pantry build <dir>
40
+ npx gm-pantry build <dir> [--shard-size N] [--prune]
39
41
  npx gm-pantry add <dir> <archive> [--id ID] [--title TITLE] [--version LABEL]
40
42
  npx gm-pantry validate <url|path> [--hosting]
41
43
  ```
@@ -43,19 +45,26 @@ npx gm-pantry validate <url|path> [--hosting]
43
45
  - `build` hashes every `.zip`, `.7z` and `.rar` at the top of the folder (streaming), derives each
44
46
  game's id and title from the file name, and writes `repo.json`. An existing `repo.json` is kept:
45
47
  hand-edited metadata, other versions and unknown fields survive, and a changed archive only has its
46
- hash and size refreshed. It never deletes an entry. `updated` moves only when something changed, so
48
+ hash and size refreshed. By default it never deletes an entry (`--prune` removes games whose archive file is gone from the
49
+ folder). Above `--shard-size` games (default 1000) it writes `shards/<sha16>.json` files of at most
50
+ N games sorted by id plus an entry `repo.json`, deletes the shards it generated earlier that are no
51
+ longer referenced, and gives the same bytes for the same input; at or below it the output stays
52
+ inline. `add` refuses a sharded repo: put the archive in the folder and run `build`. `updated` moves only when something changed, so
47
53
  a rebuild of an unchanged folder is byte-identical.
48
54
  - `add` does the same for one archive, which must be inside the directory, as a new newest version.
49
55
  - `validate` parses a `repo.json` (a URL, a folder or a file) and reports every skipped entry; for a
50
- folder it also checks each archive's hash. `--hosting` (URL only) fetches `repo.json` and a sample
56
+ folder it also loads every shard and checks each archive's hash. `--hosting` (URL only) fetches `repo.json` and a sample
51
57
  archive and checks CORS, Range, content types and validators. Exit code 1 on any problem.
52
58
 
53
59
  ## Library
54
60
 
55
61
  ```ts
56
- import { parseRepo, resolveUrl, verifyArchive, validateHosting } from "gm-pantry";
62
+ import { loadRepo, parseRepo, parseShard, verifyArchive, validateHosting } from "gm-pantry";
57
63
 
58
- const { repo, warnings } = parseRepo(await (await fetch(url)).json(), url); // throws RepoError if unusable
64
+ // repo.json plus every shard, verified by hash; throws RepoError only if repo.json is unusable
65
+ const { repo, warnings } = await loadRepo(url, { concurrency: 4, onProgress: (done, total) => {}, shardCache });
66
+ // parseRepo(json, url) and parseShard(json, url) read the two kinds of file you fetched yourself
67
+ // shardCache is { get(sha256), put(sha256, bytes) }: persist shards by hash and a hit skips the network
59
68
  const result = await verifyArchive(bytes, repo.games[0].versions[0].file); // { ok: true } or { ok: false, ... }
60
69
  ```
61
70
 
@@ -65,7 +74,8 @@ APIs.
65
74
  ## Hosting
66
75
 
67
76
  The host must serve HTTPS and send `Access-Control-Allow-Origin: *` on `repo.json` and the archives.
68
- `Range` on archives and `ETag`/`Last-Modified` on `repo.json` are recommended. Check with
77
+ `Range` on archives, `ETag`/`Last-Modified` on `repo.json`, and a long `Cache-Control: immutable` on
78
+ shards (they are named after their hash) are recommended. Check with
69
79
  `gm-pantry validate <url> --hosting`.
70
80
 
71
81
  - **GitHub Pages** serves `Access-Control-Allow-Origin: *` already. Keep archives under its size limits.
package/dist/build.d.ts CHANGED
@@ -20,9 +20,22 @@ export declare function upsertArchive(repo: Json, url: string, hashed: {
20
20
  export declare function writeRepoJson(dir: string, repo: Json, now?: string): Promise<boolean>;
21
21
  /** Every archive in `dir` (top level only), by name, as repo-relative urls. */
22
22
  export declare function listArchives(dir: string): Promise<string[]>;
23
- export declare function buildRepo(dir: string, name?: string, now?: string): Promise<{
23
+ /** Splits games, sorted by id, into content-addressed shard files of at most `size` games (SPEC.md §3.7). Pure: same input, same bytes. */
24
+ export declare function splitShards(games: Json[], size: number): {
25
+ files: Map<string, string>;
26
+ shards: Json[];
27
+ };
28
+ export interface BuildOptions {
29
+ /** Games per shard; at or below this many games the output stays inline. Default 1000. */
30
+ shardSize?: number;
31
+ /** Remove games whose archive file is gone from the folder. By default entries are kept. */
32
+ prune?: boolean;
33
+ }
34
+ export declare function buildRepo(dir: string, name?: string, now?: string, { shardSize, prune }?: BuildOptions): Promise<{
24
35
  changed: boolean;
25
36
  count: number;
37
+ shards: number;
38
+ pruned: number;
26
39
  }>;
27
40
  /** The repo-relative url of an archive that must live inside the repo directory. */
28
41
  export declare function relativeUrl(dir: string, archive: string): Promise<string>;
package/dist/build.js CHANGED
@@ -2,8 +2,9 @@
2
2
  // Works on the raw JSON, never on a parsed Repo, so hand-edited fields and unknown fields survive.
3
3
  import { createHash } from "node:crypto";
4
4
  import { createReadStream } from "node:fs";
5
- import { readFile, readdir, stat, writeFile } from "node:fs/promises";
5
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
6
6
  import { basename, extname, join, relative, sep } from "node:path";
7
+ import { LIMITS } from "./parse.js";
7
8
  export const ARCHIVE_EXTENSIONS = [".zip", ".7z", ".rar"];
8
9
  export async function hashFile(path) {
9
10
  const hash = createHash("sha256");
@@ -14,6 +15,9 @@ export async function hashFile(path) {
14
15
  }
15
16
  return { sha256: hash.digest("hex"), size };
16
17
  }
18
+ const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
19
+ const SHARD_FILE = /^[0-9a-f]{16}\.json$/;
20
+ const isRelative = (url) => !/^([a-z][a-z0-9+.-]*:|\/)/i.test(url);
17
21
  export const slug = (s) => s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
18
22
  /** "My_Game-v2.zip" -> "My Game-v2" */
19
23
  const titleOf = (file) => basename(file, extname(file)).replace(/_+/g, " ").trim();
@@ -49,7 +53,7 @@ export function upsertArchive(repo, url, hashed, opts = {}) {
49
53
  if (!game) {
50
54
  game = { id, title: opts.title ?? titleOf(decodeURIComponent(url)), versions: [] };
51
55
  games.push(game);
52
- games.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
56
+ games.sort(byId);
53
57
  }
54
58
  game.versions.unshift({ version: opts.version ?? String(game.versions.length + 1), file });
55
59
  }
@@ -76,12 +80,80 @@ export async function listArchives(dir) {
76
80
  .map((e) => e.name)
77
81
  .sort();
78
82
  }
79
- export async function buildRepo(dir, name, now) {
83
+ /** Splits games, sorted by id, into content-addressed shard files of at most `size` games (SPEC.md §3.7). Pure: same input, same bytes. */
84
+ export function splitShards(games, size) {
85
+ const sorted = [...games].sort(byId);
86
+ const files = new Map();
87
+ const shards = [];
88
+ for (let i = 0; i < sorted.length; i += size) {
89
+ const chunk = sorted.slice(i, i + size);
90
+ const content = JSON.stringify({ games: chunk }) + "\n";
91
+ const sha256 = createHash("sha256").update(content).digest("hex");
92
+ const url = `shards/${sha256.slice(0, 16)}.json`;
93
+ files.set(url, content);
94
+ shards.push({ url, sha256, size: Buffer.byteLength(content), count: chunk.length });
95
+ }
96
+ return { files, shards };
97
+ }
98
+ /** Moves the games of a sharded repo.json inline, so build can edit one flat list. */
99
+ async function inlineShards(dir, repo) {
100
+ for (const shard of repo.shards ?? []) {
101
+ if (!isRelative(shard.url))
102
+ throw new Error(`shard ${shard.url} is not a relative url; build can only rewrite shards inside ${dir}`);
103
+ const file = join(dir, decodeURIComponent(shard.url));
104
+ try {
105
+ (repo.games ??= []).push(...JSON.parse(await readFile(file, "utf8")).games);
106
+ }
107
+ catch (e) {
108
+ throw new Error(`shard ${file} is unreadable: ${e.message}`);
109
+ }
110
+ }
111
+ delete repo.shards;
112
+ }
113
+ /** Drops versions whose relative archive url no longer exists in `dir`, then games left with none. */
114
+ async function pruneMissing(dir, repo) {
115
+ for (const game of repo.games) {
116
+ const kept = [];
117
+ for (const v of game.versions ?? []) {
118
+ const url = v.file?.url ?? "";
119
+ if (!isRelative(url) || (await stat(join(dir, decodeURIComponent(url))).then(() => true, () => false)))
120
+ kept.push(v);
121
+ }
122
+ game.versions = kept;
123
+ }
124
+ const before = repo.games.length;
125
+ repo.games = repo.games.filter((g) => g.versions.length > 0);
126
+ return before - repo.games.length;
127
+ }
128
+ export async function buildRepo(dir, name, now, { shardSize = 1000, prune = false } = {}) {
129
+ if (!Number.isInteger(shardSize) || shardSize < 1 || shardSize > LIMITS.gamesPerFile) {
130
+ throw new Error(`--shard-size must be an integer from 1 to ${LIMITS.gamesPerFile}`);
131
+ }
80
132
  const repo = (await readRepoJson(dir)) ?? emptyRepo(name ?? basename(dir));
133
+ await inlineShards(dir, repo);
81
134
  const files = await listArchives(dir);
82
135
  for (const f of files)
83
136
  upsertArchive(repo, encodeURIComponent(f), await hashFile(join(dir, f)));
84
- return { changed: await writeRepoJson(dir, repo, now), count: files.length };
137
+ const pruned = prune ? await pruneMissing(dir, repo) : 0;
138
+ let out = repo;
139
+ let written = new Map();
140
+ if (repo.games.length > shardSize) {
141
+ const { files: shardFiles, shards } = splitShards(repo.games, shardSize);
142
+ if (shards.length > LIMITS.shards)
143
+ throw new Error(`${shards.length} shards exceeds the cap of ${LIMITS.shards}; raise --shard-size`);
144
+ await mkdir(join(dir, "shards"), { recursive: true });
145
+ for (const [url, content] of shardFiles)
146
+ await writeFile(join(dir, url), content);
147
+ written = shardFiles;
148
+ out = { ...repo, games: [], shards };
149
+ }
150
+ const changed = await writeRepoJson(dir, out, now);
151
+ // Only files shaped like ours are touched; anything else in shards/ is the owner's.
152
+ for (const f of await readdir(join(dir, "shards")).catch(() => [])) {
153
+ if (SHARD_FILE.test(f) && !written.has(`shards/${f}`))
154
+ await rm(join(dir, "shards", f));
155
+ }
156
+ return { changed, count: files.length, shards: written.size, pruned };
85
157
  }
86
158
  /** The repo-relative url of an archive that must live inside the repo directory. */
87
159
  export async function relativeUrl(dir, archive) {
package/dist/cli.js CHANGED
@@ -1,20 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile, stat } from "node:fs/promises";
3
- import { join, resolve } from "node:path";
3
+ import { basename, join, resolve } from "node:path";
4
4
  import { parseArgs } from "node:util";
5
5
  import { buildRepo, emptyRepo, hashFile, readRepoJson, relativeUrl, upsertArchive, writeRepoJson } from "./build.js";
6
6
  import { validateHosting } from "./hosting.js";
7
- import { parseRepo } from "./parse.js";
7
+ import { loadRepo } from "./load.js";
8
8
  import { verifyArchive } from "./verify.js";
9
9
  const USAGE = `gm-pantry init <dir> [--name NAME]
10
- gm-pantry build <dir>
10
+ gm-pantry build <dir> [--shard-size N] [--prune]
11
11
  gm-pantry add <dir> <archive> [--id ID] [--title TITLE] [--version LABEL]
12
12
  gm-pantry validate <url|path> [--hosting]`;
13
13
  async function main(argv) {
14
14
  const { positionals: [cmd, target, archive], values: o } = parseArgs({
15
15
  args: argv,
16
16
  allowPositionals: true,
17
- options: { name: { type: "string" }, id: { type: "string" }, title: { type: "string" }, version: { type: "string" }, hosting: { type: "boolean" } },
17
+ options: { name: { type: "string" }, id: { type: "string" }, title: { type: "string" }, version: { type: "string" }, hosting: { type: "boolean" }, prune: { type: "boolean" }, "shard-size": { type: "string" } },
18
18
  });
19
19
  if (!cmd || !target)
20
20
  return console.error(USAGE), 2;
@@ -27,13 +27,18 @@ async function main(argv) {
27
27
  return console.log(`wrote ${join(dir, "repo.json")}`), 0;
28
28
  }
29
29
  case "build": {
30
- const { changed, count } = await buildRepo(dir, o.name);
31
- return console.log(`${count} archive(s); repo.json ${changed ? "written" : "unchanged"}`), 0;
30
+ const { changed, count, shards, pruned } = await buildRepo(dir, o.name, undefined, {
31
+ shardSize: o["shard-size"] === undefined ? undefined : Number(o["shard-size"]),
32
+ prune: o.prune,
33
+ });
34
+ return console.log(`${count} archive(s), ${shards} shard(s), ${pruned} pruned; repo.json ${changed ? "written" : "unchanged"}`), 0;
32
35
  }
33
36
  case "add": {
34
37
  if (!archive)
35
38
  return console.error(USAGE), 2;
36
39
  const repo = (await readRepoJson(dir)) ?? emptyRepo(o.name ?? dir.split("/").pop());
40
+ if (repo.shards?.length)
41
+ return console.error("repo.json is sharded; put the archive in the folder and run build"), 1;
37
42
  upsertArchive(repo, await relativeUrl(dir, resolve(archive)), await hashFile(resolve(archive)), o);
38
43
  await writeRepoJson(dir, repo);
39
44
  return console.log("repo.json updated"), 0;
@@ -47,32 +52,34 @@ async function main(argv) {
47
52
  async function validate(target, hosting) {
48
53
  let failed = false;
49
54
  const isUrl = /^https?:\/\//.test(target);
50
- let json;
51
- let base;
52
- let dir;
53
- if (isUrl) {
54
- base = target;
55
- const res = await fetch(target);
56
- if (!res.ok)
57
- return console.error(`HTTP ${res.status}`), 1;
58
- json = await res.json();
59
- }
60
- else {
61
- dir = (await stat(target)).isDirectory() ? resolve(target) : resolve(target, "..");
62
- // Relative urls are checked against the folder itself below; resolution only needs a valid https base.
63
- base = "https://repo.invalid/";
64
- json = JSON.parse(await readFile((await stat(target)).isDirectory() ? join(target, "repo.json") : target, "utf8"));
55
+ // A local path is served to the loader through a virtual origin, so relative urls (shards, archives) resolve as they would online.
56
+ const origin = "http://localhost/";
57
+ let url = target;
58
+ let dir = "";
59
+ let localFetch;
60
+ if (!isUrl) {
61
+ const isDir = (await stat(target)).isDirectory();
62
+ dir = isDir ? resolve(target) : resolve(target, "..");
63
+ url = origin + (isDir ? "repo.json" : encodeURIComponent(basename(target)));
64
+ localFetch = async (u) => {
65
+ try {
66
+ return new Response(await readFile(join(dir, decodeURIComponent(new URL(String(u)).pathname))));
67
+ }
68
+ catch {
69
+ return new Response(null, { status: 404 });
70
+ }
71
+ };
65
72
  }
66
- const { repo, warnings } = parseRepo(json, base);
67
- console.log(`${repo.name}: ${repo.games.length} game(s)`);
73
+ const { repo, warnings } = await loadRepo(url, { fetch: localFetch });
74
+ console.log(`${repo.name}: ${repo.games.length} game(s)${repo.shards ? ` in ${repo.shards.length} shard(s) plus inline` : ""}`);
68
75
  for (const w of warnings)
69
76
  console.log(`warning ${w.path}: ${w.message}`), (failed = true);
70
- if (dir) {
77
+ if (!isUrl) {
71
78
  for (const g of repo.games)
72
79
  for (const v of g.versions) {
73
- if (!v.file.url.startsWith(base))
80
+ if (!v.file.url.startsWith(origin))
74
81
  continue;
75
- const path = join(dir, decodeURIComponent(v.file.url.slice(base.length)));
82
+ const path = join(dir, decodeURIComponent(v.file.url.slice(origin.length)));
76
83
  try {
77
84
  const r = await verifyArchive(await readFile(path), v.file);
78
85
  if (!r.ok)
package/dist/hosting.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // SPEC.md §2: does a URL actually serve a repo the way a browser consumer needs?
2
- import { parseRepo } from "./parse.js";
2
+ import { parseRepo, parseShard } from "./parse.js";
3
3
  /** Fetches repo.json and the newest archive of the first game; checks CORS, Range, content types and validators. */
4
4
  export async function validateHosting(url, fetchImpl = fetch) {
5
5
  const checks = [];
@@ -25,7 +25,9 @@ export async function validateHosting(url, fetchImpl = fetch) {
25
25
  try {
26
26
  const { repo } = parseRepo(await res.json(), res.url || url);
27
27
  archive = repo.games[0]?.versions[0]?.file.url;
28
- add("repo.json parses", "pass", `${repo.games.length} game(s)`);
28
+ add("repo.json parses", "pass", `${repo.games.length} inline game(s), ${repo.shards?.length ?? 0} shard(s)`);
29
+ if (repo.shards?.length)
30
+ archive ??= await checkShard(repo.shards[0].url, res.url || url, fetchImpl, add);
29
31
  }
30
32
  catch (e) {
31
33
  add("repo.json parses", "fail", e.message);
@@ -49,6 +51,22 @@ export async function validateHosting(url, fetchImpl = fetch) {
49
51
  }
50
52
  return report();
51
53
  }
54
+ /** Fetches one shard; returns the first archive url in it, if any. */
55
+ async function checkShard(url, repoUrl, fetchImpl, add) {
56
+ try {
57
+ const res = await fetchImpl(url);
58
+ add("shard", res.ok ? "pass" : "fail", `HTTP ${res.status}`);
59
+ if (!res.ok)
60
+ return;
61
+ cors(res, "shard cors", add);
62
+ const type = res.headers.get("content-type") ?? "";
63
+ add("shard content-type", /json|text\/plain/.test(type) ? "pass" : "warn", type || "missing");
64
+ return parseShard(await res.json(), repoUrl).games[0]?.versions[0]?.file.url;
65
+ }
66
+ catch (e) {
67
+ add("shard", "fail", `fetch failed (no CORS or unreachable): ${e.message}`);
68
+ }
69
+ }
52
70
  function cors(res, name, add) {
53
71
  const v = res.headers.get("access-control-allow-origin");
54
72
  add(name, v === "*" ? "pass" : "fail", `Access-Control-Allow-Origin: ${v ?? "missing"}`);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export type { ArchiveFile, Format, Game, Meta, Repo, Version, Warning } from "./types.ts";
2
- export { LIMITS, RepoError, isSecureUrl, parseRepo, resolveUrl } from "./parse.ts";
1
+ export type { ArchiveFile, Format, Game, Meta, Repo, Shard, Version, Warning } from "./types.ts";
2
+ export { LIMITS, RepoError, isSecureUrl, parseRepo, parseShard, resolveUrl } from "./parse.ts";
3
+ export { loadRepo, type LoadOptions, type ShardCache } from "./load.ts";
3
4
  export { verifyArchive, type VerifyResult } from "./verify.ts";
4
5
  export { validateHosting, type Check, type HostingReport } from "./hosting.ts";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
- export { LIMITS, RepoError, isSecureUrl, parseRepo, resolveUrl } from "./parse.js";
1
+ export { LIMITS, RepoError, isSecureUrl, parseRepo, parseShard, resolveUrl } from "./parse.js";
2
+ export { loadRepo } from "./load.js";
2
3
  export { verifyArchive } from "./verify.js";
3
4
  export { validateHosting } from "./hosting.js";
package/dist/load.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { Repo, Warning } from "./types.ts";
2
+ /** Lets a consumer keep shards by hash: a hit skips the network. */
3
+ export interface ShardCache {
4
+ get(sha256: string): Promise<Uint8Array | undefined>;
5
+ put(sha256: string, bytes: Uint8Array): Promise<void>;
6
+ }
7
+ export interface LoadOptions {
8
+ fetch?: typeof fetch;
9
+ /** Parallel shard downloads. Default 4. */
10
+ concurrency?: number;
11
+ /** Called after each shard is settled, loaded or skipped. */
12
+ onProgress?: (done: number, total: number) => void;
13
+ shardCache?: ShardCache;
14
+ }
15
+ /** Fetches repo.json and its shards. Throws RepoError only if repo.json itself is unusable; a bad shard is skipped and reported. */
16
+ export declare function loadRepo(url: string, opts?: LoadOptions): Promise<{
17
+ repo: Repo;
18
+ warnings: Warning[];
19
+ }>;
package/dist/load.js ADDED
@@ -0,0 +1,61 @@
1
+ // SPEC.md §4.6: loading a whole repo, shards included. Web Crypto and fetch only, so it runs in a browser.
2
+ import { RepoError, parseRepo, parseShard } from "./parse.js";
3
+ import { verifyArchive } from "./verify.js";
4
+ /** Fetches repo.json and its shards. Throws RepoError only if repo.json itself is unusable; a bad shard is skipped and reported. */
5
+ export async function loadRepo(url, opts = {}) {
6
+ const fetchImpl = opts.fetch ?? fetch;
7
+ const res = await fetchImpl(url);
8
+ if (!res.ok)
9
+ throw new RepoError(`repo.json: HTTP ${res.status}`);
10
+ const entryUrl = res.url || url;
11
+ const { repo, warnings } = parseRepo(await res.json(), entryUrl);
12
+ const shards = repo.shards ?? [];
13
+ const seen = new Set(repo.games.map((g) => g.id));
14
+ const results = new Array(shards.length);
15
+ let next = 0;
16
+ let done = 0;
17
+ const worker = async () => {
18
+ while (next < shards.length) {
19
+ const i = next++;
20
+ results[i] = await loadShard(shards[i], fetchImpl, opts.shardCache);
21
+ opts.onProgress?.(++done, shards.length);
22
+ }
23
+ };
24
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(opts.concurrency ?? 4, shards.length)) }, worker));
25
+ // Merged in shard order, after all downloads, so the result does not depend on which one finished first.
26
+ results.forEach((r, i) => {
27
+ if ("error" in r)
28
+ return void warnings.push({ path: `shards[${i}]`, message: `skipped: ${r.error}` });
29
+ try {
30
+ const shard = parseShard(r.json, entryUrl, seen);
31
+ repo.games.push(...shard.games);
32
+ for (const w of shard.warnings)
33
+ warnings.push({ path: `shards[${i}].${w.path}`, message: w.message });
34
+ }
35
+ catch (e) {
36
+ warnings.push({ path: `shards[${i}]`, message: `skipped: ${e.message}` });
37
+ }
38
+ });
39
+ return { repo, warnings };
40
+ }
41
+ async function loadShard(shard, fetchImpl, cache) {
42
+ try {
43
+ let bytes = await cache?.get(shard.sha256);
44
+ if (bytes && !(await verifyArchive(bytes, shard)).ok)
45
+ bytes = undefined; // a corrupt cache entry is a miss
46
+ if (!bytes) {
47
+ const res = await fetchImpl(shard.url);
48
+ if (!res.ok)
49
+ return { error: `HTTP ${res.status}` };
50
+ bytes = new Uint8Array(await res.arrayBuffer());
51
+ const check = await verifyArchive(bytes, shard);
52
+ if (!check.ok)
53
+ return { error: `${check.reason} is ${check.actual}, repo.json says ${check.expected}` };
54
+ await cache?.put(shard.sha256, bytes).catch(() => { }); // a failing cache must not lose the shard
55
+ }
56
+ return { json: JSON.parse(new TextDecoder().decode(bytes)) };
57
+ }
58
+ catch (e) {
59
+ return { error: e.message };
60
+ }
61
+ }
package/dist/parse.d.ts CHANGED
@@ -1,9 +1,10 @@
1
- import type { Repo, Warning } from "./types.ts";
1
+ import type { Game, Repo, Warning } from "./types.ts";
2
2
  /** The caps of SPEC.md §4.4. */
3
3
  export declare const LIMITS: {
4
4
  readonly name: 80;
5
5
  readonly repoDescription: 280;
6
- readonly games: 10000;
6
+ readonly gamesPerFile: 2000;
7
+ readonly shards: 1000;
7
8
  readonly id: 64;
8
9
  readonly title: 200;
9
10
  readonly versions: 200;
@@ -32,3 +33,8 @@ export declare function parseRepo(json: unknown, baseUrl: string): {
32
33
  repo: Repo;
33
34
  warnings: Warning[];
34
35
  };
36
+ /** Parses a shard file already decoded from JSON (SPEC.md §3.7). `baseUrl` is the url of repo.json, not of the shard. `seen` carries ids already taken elsewhere in the repo. Throws RepoError. */
37
+ export declare function parseShard(json: unknown, baseUrl: string, seen?: Set<string>): {
38
+ games: Game[];
39
+ warnings: Warning[];
40
+ };
package/dist/parse.js CHANGED
@@ -4,7 +4,8 @@
4
4
  export const LIMITS = {
5
5
  name: 80,
6
6
  repoDescription: 280,
7
- games: 10_000,
7
+ gamesPerFile: 2000,
8
+ shards: 1000,
8
9
  id: 64,
9
10
  title: 200,
10
11
  versions: 200,
@@ -67,8 +68,12 @@ export function parseRepo(json, baseUrl) {
67
68
  throw new RepoError(`"name" must be a non-empty string of at most ${LIMITS.name} characters`);
68
69
  if (!validDate(json.updated))
69
70
  throw new RepoError(`"updated" must be an ISO-8601 date or timestamp`);
70
- if (!Array.isArray(json.games))
71
+ if (json.games === undefined && json.shards === undefined)
72
+ throw new RepoError(`"games" or "shards" must be present`);
73
+ if (json.games !== undefined && !Array.isArray(json.games))
71
74
  throw new RepoError(`"games" must be an array`);
75
+ if (json.shards !== undefined && !Array.isArray(json.shards))
76
+ throw new RepoError(`"shards" must be an array`);
72
77
  const repo = { schema: 1, name, updated: json.updated, games: [] };
73
78
  const description = text(json.description, LIMITS.repoDescription);
74
79
  if (description)
@@ -79,8 +84,32 @@ export function parseRepo(json, baseUrl) {
79
84
  repo.homepage = json.homepage;
80
85
  else if (json.homepage !== undefined)
81
86
  warn("homepage", "dropped: not an https url");
82
- const seen = new Set();
83
- json.games.slice(0, LIMITS.games).forEach((entry, i) => {
87
+ repo.games = parseGames(json.games ?? [], baseUrl, new Set(), warn);
88
+ if (json.shards) {
89
+ const shards = [];
90
+ json.shards.slice(0, LIMITS.shards).forEach((entry, i) => {
91
+ const shard = parseShardRef(entry, `shards[${i}]`, baseUrl, warn);
92
+ if (shard)
93
+ shards.push(shard);
94
+ });
95
+ if (json.shards.length > LIMITS.shards)
96
+ warn("shards", `only the first ${LIMITS.shards} entries are read`);
97
+ if (shards.length)
98
+ repo.shards = shards;
99
+ }
100
+ return { repo, warnings };
101
+ }
102
+ /** Parses a shard file already decoded from JSON (SPEC.md §3.7). `baseUrl` is the url of repo.json, not of the shard. `seen` carries ids already taken elsewhere in the repo. Throws RepoError. */
103
+ export function parseShard(json, baseUrl, seen = new Set()) {
104
+ const warnings = [];
105
+ if (!isObject(json) || !Array.isArray(json.games))
106
+ throw new RepoError(`a shard must be an object with a "games" array`);
107
+ const games = parseGames(json.games, baseUrl, seen, (path, message) => warnings.push({ path, message }));
108
+ return { games, warnings };
109
+ }
110
+ function parseGames(list, baseUrl, seen, warn) {
111
+ const games = [];
112
+ list.slice(0, LIMITS.gamesPerFile).forEach((entry, i) => {
84
113
  const path = `games[${i}]`;
85
114
  const game = parseGame(entry, path, baseUrl, warn);
86
115
  if (!game)
@@ -88,11 +117,28 @@ export function parseRepo(json, baseUrl) {
88
117
  if (seen.has(game.id))
89
118
  return warn(path, `skipped: duplicate id "${game.id}"`);
90
119
  seen.add(game.id);
91
- repo.games.push(game);
120
+ games.push(game);
92
121
  });
93
- if (json.games.length > LIMITS.games)
94
- warn("games", `only the first ${LIMITS.games} entries are read`);
95
- return { repo, warnings };
122
+ if (list.length > LIMITS.gamesPerFile)
123
+ warn("games", `only the first ${LIMITS.gamesPerFile} entries are read`);
124
+ return games;
125
+ }
126
+ function parseShardRef(entry, path, baseUrl, warn) {
127
+ if (!isObject(entry))
128
+ return void warn(path, "skipped: not an object");
129
+ const url = typeof entry.url === "string" && entry.url.length <= LIMITS.url ? resolveUrl(entry.url, baseUrl) : undefined;
130
+ if (url === undefined || !isSecureUrl(url))
131
+ return void warn(path, `skipped: "url" must resolve to an https url`);
132
+ if (typeof entry.sha256 !== "string" || !SHA256.test(entry.sha256))
133
+ return void warn(path, `skipped: "sha256" must be 64 lowercase hex digits`);
134
+ if (!Number.isSafeInteger(entry.size) || entry.size < 0)
135
+ return void warn(path, `skipped: "size" must be a non-negative integer`);
136
+ const shard = { url, sha256: entry.sha256, size: entry.size };
137
+ if (Number.isSafeInteger(entry.count) && entry.count >= 0)
138
+ shard.count = entry.count;
139
+ else if (entry.count !== undefined)
140
+ warn(`${path}.count`, "dropped: not a non-negative integer");
141
+ return shard;
96
142
  }
97
143
  function parseGame(entry, path, baseUrl, warn) {
98
144
  if (!isObject(entry))
package/dist/types.d.ts CHANGED
@@ -35,13 +35,25 @@ export interface Game {
35
35
  /** Non-empty, newest first. */
36
36
  versions: Version[];
37
37
  }
38
+ /** A file of games listed by repo.json. Content-addressed: its bytes must hash to `sha256`. */
39
+ export interface Shard {
40
+ url: string;
41
+ /** Lowercase hex SHA-256 of the shard file, exactly as stored. */
42
+ sha256: string;
43
+ /** Bytes. */
44
+ size: number;
45
+ /** Number of games in it; advisory. */
46
+ count?: number;
47
+ }
38
48
  export interface Repo {
39
49
  schema: 1;
40
50
  name: string;
41
51
  description?: string;
42
52
  homepage?: string;
43
53
  updated: string;
54
+ /** Inline games; with `shards`, these come first. */
44
55
  games: Game[];
56
+ shards?: Shard[];
45
57
  }
46
58
  /** Something skipped or dropped while parsing; `path` is a JSON path such as `games[3].versions[0]`. */
47
59
  export interface Warning {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm-pantry",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Game repositories: static directories of game archives with a repo.json, plus a parser, validator and CLI.",
5
5
  "keywords": [
6
6
  "gamemaker",