gm-pantry 0.1.0 → 0.3.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/dist/cli.js CHANGED
@@ -1,43 +1,132 @@
1
1
  #!/usr/bin/env node
2
+ import { openAsBlob } from "node:fs";
2
3
  import { readFile, stat } from "node:fs/promises";
3
- import { join, resolve } from "node:path";
4
+ import { basename, join, resolve } from "node:path";
4
5
  import { parseArgs } from "node:util";
5
- import { buildRepo, emptyRepo, hashFile, readRepoJson, relativeUrl, upsertArchive, writeRepoJson } from "./build.js";
6
+ import { emptyRepo, serialize, upsertArchive } from "./builder.js";
7
+ import { buildRepo, hashFile, readRepoJson, relativeUrl, writeRepoJson } from "./build.js";
6
8
  import { validateHosting } from "./hosting.js";
7
- import { parseRepo } from "./parse.js";
9
+ import { loadRepo } from "./load.js";
10
+ import { S3Store, checkCors, corsSnippets, publishRepo } from "./s3.js";
8
11
  import { verifyArchive } from "./verify.js";
9
12
  const USAGE = `gm-pantry init <dir> [--name NAME]
10
- gm-pantry build <dir>
13
+ gm-pantry build <dir> [--shard-size N] [--prune]
11
14
  gm-pantry add <dir> <archive> [--id ID] [--title TITLE] [--version LABEL]
12
- gm-pantry validate <url|path> [--hosting]`;
15
+ gm-pantry validate <url|path> [--hosting]
16
+ gm-pantry publish <dir> --endpoint URL --bucket NAME --region R [--prefix P] [--shard-size N] [--path-style|--virtual-host]
17
+ gm-pantry cors --endpoint URL --bucket NAME --region R [--prefix P] [--origin O] [--public-url URL] [--apply]
18
+
19
+ publish and cors read credentials from AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (and AWS_SESSION_TOKEN)
20
+ or --access-key-id / --secret-access-key / --session-token.`;
21
+ const S3_OPTIONS = {
22
+ endpoint: { type: "string" }, bucket: { type: "string" }, region: { type: "string" }, prefix: { type: "string" },
23
+ "access-key-id": { type: "string" }, "secret-access-key": { type: "string" }, "session-token": { type: "string" },
24
+ "path-style": { type: "boolean" }, "virtual-host": { type: "boolean" },
25
+ origin: { type: "string" }, "public-url": { type: "string" }, apply: { type: "boolean" },
26
+ };
27
+ function s3Config(o) {
28
+ const env = process.env;
29
+ const cfg = {
30
+ endpoint: o.endpoint ?? env.AWS_ENDPOINT_URL,
31
+ bucket: o.bucket,
32
+ region: o.region ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION,
33
+ accessKeyId: o["access-key-id"] ?? env.AWS_ACCESS_KEY_ID,
34
+ secretAccessKey: o["secret-access-key"] ?? env.AWS_SECRET_ACCESS_KEY,
35
+ sessionToken: o["session-token"] ?? env.AWS_SESSION_TOKEN,
36
+ };
37
+ const missing = Object.entries(cfg).filter(([k, v]) => !v && k !== "sessionToken").map(([k]) => k);
38
+ if (missing.length)
39
+ throw new Error(`missing ${missing.join(", ")} (see usage)`);
40
+ return { ...cfg, prefix: o.prefix, forcePathStyle: o["path-style"] ? true : o["virtual-host"] ? false : undefined };
41
+ }
42
+ /** Builds the folder's repo.json, then uploads the archives it lists, the shards and repo.json (in that order). */
43
+ async function publish(dir, o) {
44
+ const store = new S3Store(s3Config(o));
45
+ const shardSize = o["shard-size"] === undefined ? undefined : Number(o["shard-size"]);
46
+ await buildRepo(dir, o.name, undefined, { shardSize });
47
+ const repoJson = await readFile(join(dir, "repo.json"), "utf8");
48
+ const repo = JSON.parse(repoJson);
49
+ const shards = new Map();
50
+ for (const s of repo.shards ?? [])
51
+ shards.set(s.url, await readFile(join(dir, decodeURIComponent(s.url)), "utf8"));
52
+ const archives = [];
53
+ for (const g of [...repo.games, ...[...shards.values()].flatMap((c) => JSON.parse(c).games)]) {
54
+ for (const v of g.versions) {
55
+ const url = v.file.url;
56
+ if (/^([a-z][a-z0-9+.-]*:|\/)/i.test(url))
57
+ continue; // not ours to upload
58
+ archives.push({ url, body: await openAsBlob(join(dir, decodeURIComponent(url))), sha256: v.file.sha256 });
59
+ }
60
+ }
61
+ const t = await store.testConnection();
62
+ if (!t.ok)
63
+ return t.checks.forEach((c) => console.error(`${c.status.padEnd(4)} ${c.name}: ${c.message}`)), 1;
64
+ const r = await publishRepo(store, { repoJson, shards }, {
65
+ archives,
66
+ onProgress: (url, done, total) => process.stderr.write(`\r${url}: ${Math.floor((done / total) * 100)}% `),
67
+ });
68
+ console.log(`\nuploaded ${r.uploaded.length}, skipped ${r.skipped.length}, deleted ${r.deleted.length} old shard(s)`);
69
+ if (!r.conditional)
70
+ console.log("warning: the service ignores conditional writes, so a concurrent publisher could be overwritten (last writer wins)");
71
+ return 0;
72
+ }
73
+ async function cors(o) {
74
+ const store = new S3Store(s3Config(o));
75
+ const origin = o.origin;
76
+ if (o.apply) {
77
+ await store.setCors(origin ? [origin] : ["*"]);
78
+ return console.log("bucket CORS configuration replaced"), 0;
79
+ }
80
+ const publicUrl = o["public-url"];
81
+ const snippets = corsSnippets(origin);
82
+ if (publicUrl) {
83
+ const report = await checkCors(publicUrl, origin, { writeUrl: store.url("repo.json").href });
84
+ for (const c of report.checks)
85
+ console.log(`${c.status.padEnd(4)} ${c.name}: ${c.message}`);
86
+ if (report.ok)
87
+ return 0;
88
+ }
89
+ console.log(`S3 (${snippets.s3.command}):\n${snippets.s3.json}\n\nR2 dashboard JSON (or: ${snippets.r2.command}, with the wrangler JSON below):\n${snippets.r2.json}\n${snippets.r2.wranglerJson}\n\nB2: ${snippets.b2.command}\n\nor run this command again with --apply (needs a key that may call PutBucketCors).`);
90
+ return publicUrl ? 1 : 0;
91
+ }
13
92
  async function main(argv) {
14
93
  const { positionals: [cmd, target, archive], values: o } = parseArgs({
15
94
  args: argv,
16
95
  allowPositionals: true,
17
- options: { name: { type: "string" }, id: { type: "string" }, title: { type: "string" }, version: { type: "string" }, hosting: { type: "boolean" } },
96
+ options: { ...S3_OPTIONS, name: { type: "string" }, id: { type: "string" }, title: { type: "string" }, version: { type: "string" }, hosting: { type: "boolean" }, prune: { type: "boolean" }, "shard-size": { type: "string" } },
18
97
  });
19
- if (!cmd || !target)
98
+ if (!cmd || (!target && cmd !== "cors"))
20
99
  return console.error(USAGE), 2;
21
- const dir = resolve(target);
100
+ const dir = resolve(target ?? ".");
22
101
  switch (cmd) {
23
102
  case "init": {
24
103
  if (await readRepoJson(dir))
25
104
  return console.error("repo.json already exists"), 1;
26
- await writeRepoJson(dir, emptyRepo(o.name ?? dir.split("/").pop()));
105
+ await writeRepoJson(dir, (await serialize(emptyRepo(o.name ?? dir.split("/").pop()))).repoJson);
27
106
  return console.log(`wrote ${join(dir, "repo.json")}`), 0;
28
107
  }
29
108
  case "build": {
30
- const { changed, count } = await buildRepo(dir, o.name);
31
- return console.log(`${count} archive(s); repo.json ${changed ? "written" : "unchanged"}`), 0;
109
+ const { changed, count, shards, pruned } = await buildRepo(dir, o.name, undefined, {
110
+ shardSize: o["shard-size"] === undefined ? undefined : Number(o["shard-size"]),
111
+ prune: o.prune,
112
+ });
113
+ return console.log(`${count} archive(s), ${shards} shard(s), ${pruned} pruned; repo.json ${changed ? "written" : "unchanged"}`), 0;
32
114
  }
33
115
  case "add": {
34
116
  if (!archive)
35
117
  return console.error(USAGE), 2;
36
118
  const repo = (await readRepoJson(dir)) ?? emptyRepo(o.name ?? dir.split("/").pop());
119
+ if (repo.shards?.length)
120
+ return console.error("repo.json is sharded; put the archive in the folder and run build"), 1;
121
+ const previous = structuredClone(repo);
37
122
  upsertArchive(repo, await relativeUrl(dir, resolve(archive)), await hashFile(resolve(archive)), o);
38
- await writeRepoJson(dir, repo);
123
+ await writeRepoJson(dir, (await serialize(repo, { previous })).repoJson);
39
124
  return console.log("repo.json updated"), 0;
40
125
  }
126
+ case "publish":
127
+ return publish(dir, o);
128
+ case "cors":
129
+ return cors(o);
41
130
  case "validate":
42
131
  return validate(target, o.hosting === true);
43
132
  default:
@@ -47,32 +136,34 @@ async function main(argv) {
47
136
  async function validate(target, hosting) {
48
137
  let failed = false;
49
138
  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"));
139
+ // A local path is served to the loader through a virtual origin, so relative urls (shards, archives) resolve as they would online.
140
+ const origin = "http://localhost/";
141
+ let url = target;
142
+ let dir = "";
143
+ let localFetch;
144
+ if (!isUrl) {
145
+ const isDir = (await stat(target)).isDirectory();
146
+ dir = isDir ? resolve(target) : resolve(target, "..");
147
+ url = origin + (isDir ? "repo.json" : encodeURIComponent(basename(target)));
148
+ localFetch = async (u) => {
149
+ try {
150
+ return new Response(await readFile(join(dir, decodeURIComponent(new URL(String(u)).pathname))));
151
+ }
152
+ catch {
153
+ return new Response(null, { status: 404 });
154
+ }
155
+ };
65
156
  }
66
- const { repo, warnings } = parseRepo(json, base);
67
- console.log(`${repo.name}: ${repo.games.length} game(s)`);
157
+ const { repo, warnings } = await loadRepo(url, { fetch: localFetch });
158
+ console.log(`${repo.name}: ${repo.games.length} game(s)${repo.shards ? ` in ${repo.shards.length} shard(s) plus inline` : ""}`);
68
159
  for (const w of warnings)
69
160
  console.log(`warning ${w.path}: ${w.message}`), (failed = true);
70
- if (dir) {
161
+ if (!isUrl) {
71
162
  for (const g of repo.games)
72
163
  for (const v of g.versions) {
73
- if (!v.file.url.startsWith(base))
164
+ if (!v.file.url.startsWith(origin))
74
165
  continue;
75
- const path = join(dir, decodeURIComponent(v.file.url.slice(base.length)));
166
+ const path = join(dir, decodeURIComponent(v.file.url.slice(origin.length)));
76
167
  try {
77
168
  const r = await verifyArchive(await readFile(path), v.file);
78
169
  if (!r.ok)
package/dist/hash.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ export interface HashOptions {
2
+ /** Called after every chunk with the bytes hashed so far. */
3
+ onProgress?: (bytes: number) => void;
4
+ signal?: AbortSignal;
5
+ }
6
+ export interface Hashed {
7
+ sha256: string;
8
+ size: number;
9
+ }
10
+ /** Hashes a stream chunk by chunk; memory use is one chunk, whatever the size. */
11
+ export declare function hashStream(stream: ReadableStream<Uint8Array>, { onProgress, signal }?: HashOptions): Promise<Hashed>;
12
+ /** Hashes a Blob or File (a 4 GB file works: it is read as a stream, never as one buffer). */
13
+ export declare const hashBlob: (blob: Blob, options?: HashOptions) => Promise<Hashed>;
14
+ /** Lowercase hex SHA-256 of bytes already in memory. */
15
+ export declare function sha256Hex(bytes: BufferSource): Promise<string>;
package/dist/hash.js ADDED
@@ -0,0 +1,31 @@
1
+ // Streaming SHA-256 for archives too big to buffer. Web Crypto has no incremental digest, so hash-wasm does the work.
2
+ // Browser-safe: no Node APIs.
3
+ import { createSHA256 } from "hash-wasm";
4
+ /** Hashes a stream chunk by chunk; memory use is one chunk, whatever the size. */
5
+ export async function hashStream(stream, { onProgress, signal } = {}) {
6
+ const hasher = await createSHA256();
7
+ const reader = stream.getReader();
8
+ let size = 0;
9
+ try {
10
+ for (;;) {
11
+ signal?.throwIfAborted();
12
+ const { done, value } = await reader.read();
13
+ if (done)
14
+ break;
15
+ hasher.update(value);
16
+ size += value.byteLength;
17
+ onProgress?.(size);
18
+ }
19
+ }
20
+ catch (e) {
21
+ await reader.cancel().catch(() => { });
22
+ throw e;
23
+ }
24
+ return { sha256: hasher.digest(), size };
25
+ }
26
+ /** Hashes a Blob or File (a 4 GB file works: it is read as a stream, never as one buffer). */
27
+ export const hashBlob = (blob, options) => hashStream(blob.stream(), options);
28
+ /** Lowercase hex SHA-256 of bytes already in memory. */
29
+ export async function sha256Hex(bytes) {
30
+ return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((b) => b.toString(16).padStart(2, "0")).join("");
31
+ }
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,7 @@
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";
6
+ export { hashBlob, hashStream, type HashOptions, type Hashed } from "./hash.ts";
7
+ export { addVersion, emptyRepo, inlineShards, removeGame, removeVersion, serialize, slug, splitShards, updateGame, type GamePatch, type Json, type NewVersion, type SerializeOptions, type Serialized, } from "./builder.ts";
package/dist/index.js CHANGED
@@ -1,3 +1,6 @@
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";
5
+ export { hashBlob, hashStream } from "./hash.js";
6
+ export { addVersion, emptyRepo, inlineShards, removeGame, removeVersion, serialize, slug, splitShards, updateGame, } from "./builder.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))