gm-pantry 0.0.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/LICENSE +21 -0
- package/README.md +101 -0
- package/dist/build.d.ts +42 -0
- package/dist/build.js +165 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +104 -0
- package/dist/hosting.d.ts +11 -0
- package/dist/hosting.js +73 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/load.d.ts +19 -0
- package/dist/load.js +61 -0
- package/dist/parse.d.ts +40 -0
- package/dist/parse.js +264 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.js +2 -0
- package/dist/verify.d.ts +14 -0
- package/dist/verify.js +15 -0
- package/package.json +50 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dapper Mink
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# GM-Pantry
|
|
2
|
+
|
|
3
|
+
A format for game repositories: a static directory served over HTTPS that lists game archives, with
|
|
4
|
+
a TypeScript library, a validator and a CLI. The specification is [SPEC.md](SPEC.md), at revision 1.
|
|
5
|
+
|
|
6
|
+
## The format at a glance
|
|
7
|
+
|
|
8
|
+
A repo is a directory. Its entry point is `repo.json`, and the URL of that file is the repo's identity.
|
|
9
|
+
|
|
10
|
+
```json
|
|
11
|
+
{
|
|
12
|
+
"schema": 1,
|
|
13
|
+
"name": "Example Pantry",
|
|
14
|
+
"updated": "2026-11-15",
|
|
15
|
+
"games": [
|
|
16
|
+
{
|
|
17
|
+
"id": "my-fangame",
|
|
18
|
+
"title": "My Fangame",
|
|
19
|
+
"work": { "delfruit": 1234 },
|
|
20
|
+
"versions": [
|
|
21
|
+
{ "version": "1.0", "file": { "url": "My_Fangame.zip", "sha256": "<64 hex digits>", "size": 1048576 } }
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- Archives are stored exactly as distributed and are never modified. Their sha256 is checked after
|
|
29
|
+
download; a mismatch discards the file.
|
|
30
|
+
- File urls may be relative, so a mirror of the directory is a working repo.
|
|
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.
|
|
34
|
+
- Nothing lists or indexes repos. A private repo is an unguessable URL.
|
|
35
|
+
|
|
36
|
+
## CLI
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
npx gm-pantry init <dir> [--name NAME]
|
|
40
|
+
npx gm-pantry build <dir> [--shard-size N] [--prune]
|
|
41
|
+
npx gm-pantry add <dir> <archive> [--id ID] [--title TITLE] [--version LABEL]
|
|
42
|
+
npx gm-pantry validate <url|path> [--hosting]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- `build` hashes every `.zip`, `.7z` and `.rar` at the top of the folder (streaming), derives each
|
|
46
|
+
game's id and title from the file name, and writes `repo.json`. An existing `repo.json` is kept:
|
|
47
|
+
hand-edited metadata, other versions and unknown fields survive, and a changed archive only has its
|
|
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
|
|
53
|
+
a rebuild of an unchanged folder is byte-identical.
|
|
54
|
+
- `add` does the same for one archive, which must be inside the directory, as a new newest version.
|
|
55
|
+
- `validate` parses a `repo.json` (a URL, a folder or a file) and reports every skipped entry; for a
|
|
56
|
+
folder it also loads every shard and checks each archive's hash. `--hosting` (URL only) fetches `repo.json` and a sample
|
|
57
|
+
archive and checks CORS, Range, content types and validators. Exit code 1 on any problem.
|
|
58
|
+
|
|
59
|
+
## Library
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { loadRepo, parseRepo, parseShard, verifyArchive, validateHosting } from "gm-pantry";
|
|
63
|
+
|
|
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
|
|
68
|
+
const result = await verifyArchive(bytes, repo.games[0].versions[0].file); // { ok: true } or { ok: false, ... }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The library uses Web Crypto and `fetch` only, so it runs in browsers and Node. Only the CLI uses Node
|
|
72
|
+
APIs.
|
|
73
|
+
|
|
74
|
+
## Hosting
|
|
75
|
+
|
|
76
|
+
The host must serve HTTPS and send `Access-Control-Allow-Origin: *` on `repo.json` and the archives.
|
|
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
|
|
79
|
+
`gm-pantry validate <url> --hosting`.
|
|
80
|
+
|
|
81
|
+
- **GitHub Pages** serves `Access-Control-Allow-Origin: *` already. Keep archives under its size limits.
|
|
82
|
+
- **Amazon S3** needs a CORS rule on the bucket:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
[{ "AllowedOrigins": ["*"], "AllowedMethods": ["GET", "HEAD"], "AllowedHeaders": ["Range", "If-None-Match", "If-Modified-Since"], "ExposeHeaders": ["Content-Length", "Content-Range", "ETag"], "MaxAgeSeconds": 86400 }]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
- **Cloudflare R2** takes the same rule in the bucket's CORS policy (dashboard, Settings, CORS policy).
|
|
89
|
+
- **Backblaze B2** needs CORS rules on the bucket allowing `GET`/`HEAD` from any origin (its S3
|
|
90
|
+
compatible API accepts the S3 rule above).
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
pnpm install
|
|
96
|
+
pnpm run typecheck && pnpm test && pnpm run build
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`conformance/` holds `repo.json` inputs with their expected result; a reader in any language can use them.
|
|
100
|
+
|
|
101
|
+
MIT licensed.
|
package/dist/build.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
type Json = Record<string, any>;
|
|
2
|
+
export declare const ARCHIVE_EXTENSIONS: string[];
|
|
3
|
+
export declare function hashFile(path: string): Promise<{
|
|
4
|
+
sha256: string;
|
|
5
|
+
size: number;
|
|
6
|
+
}>;
|
|
7
|
+
export declare const slug: (s: string) => string;
|
|
8
|
+
export declare function readRepoJson(dir: string): Promise<Json | undefined>;
|
|
9
|
+
export declare function emptyRepo(name: string): Json;
|
|
10
|
+
/** Adds an archive, or refreshes sha256 and size of the version that already points at it. */
|
|
11
|
+
export declare function upsertArchive(repo: Json, url: string, hashed: {
|
|
12
|
+
sha256: string;
|
|
13
|
+
size: number;
|
|
14
|
+
}, opts?: {
|
|
15
|
+
id?: string;
|
|
16
|
+
title?: string;
|
|
17
|
+
version?: string;
|
|
18
|
+
}): void;
|
|
19
|
+
/** Serialises repo.json; `updated` moves only when something else changed, so a rebuild is byte-identical. */
|
|
20
|
+
export declare function writeRepoJson(dir: string, repo: Json, now?: string): Promise<boolean>;
|
|
21
|
+
/** Every archive in `dir` (top level only), by name, as repo-relative urls. */
|
|
22
|
+
export declare function listArchives(dir: string): Promise<string[]>;
|
|
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<{
|
|
35
|
+
changed: boolean;
|
|
36
|
+
count: number;
|
|
37
|
+
shards: number;
|
|
38
|
+
pruned: number;
|
|
39
|
+
}>;
|
|
40
|
+
/** The repo-relative url of an archive that must live inside the repo directory. */
|
|
41
|
+
export declare function relativeUrl(dir: string, archive: string): Promise<string>;
|
|
42
|
+
export {};
|
package/dist/build.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Writing a repo.json (SPEC.md §7): node-only, kept out of the library entry point.
|
|
2
|
+
// Works on the raw JSON, never on a parsed Repo, so hand-edited fields and unknown fields survive.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { createReadStream } from "node:fs";
|
|
5
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { basename, extname, join, relative, sep } from "node:path";
|
|
7
|
+
import { LIMITS } from "./parse.js";
|
|
8
|
+
export const ARCHIVE_EXTENSIONS = [".zip", ".7z", ".rar"];
|
|
9
|
+
export async function hashFile(path) {
|
|
10
|
+
const hash = createHash("sha256");
|
|
11
|
+
let size = 0;
|
|
12
|
+
for await (const chunk of createReadStream(path)) {
|
|
13
|
+
hash.update(chunk);
|
|
14
|
+
size += chunk.length;
|
|
15
|
+
}
|
|
16
|
+
return { sha256: hash.digest("hex"), size };
|
|
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);
|
|
21
|
+
export const slug = (s) => s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
22
|
+
/** "My_Game-v2.zip" -> "My Game-v2" */
|
|
23
|
+
const titleOf = (file) => basename(file, extname(file)).replace(/_+/g, " ").trim();
|
|
24
|
+
export async function readRepoJson(dir) {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(await readFile(join(dir, "repo.json"), "utf8"));
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
if (e.code === "ENOENT")
|
|
30
|
+
return undefined;
|
|
31
|
+
throw new Error(`repo.json in ${dir} is unreadable: ${e.message}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function emptyRepo(name) {
|
|
35
|
+
return { schema: 1, name, updated: new Date().toISOString(), games: [] };
|
|
36
|
+
}
|
|
37
|
+
/** Adds an archive, or refreshes sha256 and size of the version that already points at it. */
|
|
38
|
+
export function upsertArchive(repo, url, hashed, opts = {}) {
|
|
39
|
+
const file = { url, ...hashed };
|
|
40
|
+
const games = (repo.games ??= []);
|
|
41
|
+
for (const game of games) {
|
|
42
|
+
for (const v of game.versions ?? []) {
|
|
43
|
+
if (v.file?.url === url) {
|
|
44
|
+
v.file = { ...v.file, ...hashed };
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const id = opts.id ?? slug(titleOf(decodeURIComponent(url)));
|
|
50
|
+
if (id === "")
|
|
51
|
+
throw new Error(`cannot derive an id from ${url}; pass --id`);
|
|
52
|
+
let game = games.find((g) => g.id === id);
|
|
53
|
+
if (!game) {
|
|
54
|
+
game = { id, title: opts.title ?? titleOf(decodeURIComponent(url)), versions: [] };
|
|
55
|
+
games.push(game);
|
|
56
|
+
games.sort(byId);
|
|
57
|
+
}
|
|
58
|
+
game.versions.unshift({ version: opts.version ?? String(game.versions.length + 1), file });
|
|
59
|
+
}
|
|
60
|
+
/** Serialises repo.json; `updated` moves only when something else changed, so a rebuild is byte-identical. */
|
|
61
|
+
export async function writeRepoJson(dir, repo, now = new Date().toISOString()) {
|
|
62
|
+
const before = await readFile(join(dir, "repo.json"), "utf8").catch(() => undefined);
|
|
63
|
+
const stable = (r) => JSON.stringify({ ...r, updated: undefined });
|
|
64
|
+
let old;
|
|
65
|
+
try {
|
|
66
|
+
old = before ? JSON.parse(before) : undefined;
|
|
67
|
+
}
|
|
68
|
+
catch { /* rewritten below */ }
|
|
69
|
+
const next = { ...repo, updated: old && stable(old) === stable(repo) ? old.updated : now };
|
|
70
|
+
const out = JSON.stringify(next, null, 2) + "\n";
|
|
71
|
+
if (out === before)
|
|
72
|
+
return false;
|
|
73
|
+
await writeFile(join(dir, "repo.json"), out);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
/** Every archive in `dir` (top level only), by name, as repo-relative urls. */
|
|
77
|
+
export async function listArchives(dir) {
|
|
78
|
+
return (await readdir(dir, { withFileTypes: true }))
|
|
79
|
+
.filter((e) => e.isFile() && ARCHIVE_EXTENSIONS.includes(extname(e.name).toLowerCase()))
|
|
80
|
+
.map((e) => e.name)
|
|
81
|
+
.sort();
|
|
82
|
+
}
|
|
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
|
+
}
|
|
132
|
+
const repo = (await readRepoJson(dir)) ?? emptyRepo(name ?? basename(dir));
|
|
133
|
+
await inlineShards(dir, repo);
|
|
134
|
+
const files = await listArchives(dir);
|
|
135
|
+
for (const f of files)
|
|
136
|
+
upsertArchive(repo, encodeURIComponent(f), await hashFile(join(dir, f)));
|
|
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 };
|
|
157
|
+
}
|
|
158
|
+
/** The repo-relative url of an archive that must live inside the repo directory. */
|
|
159
|
+
export async function relativeUrl(dir, archive) {
|
|
160
|
+
await stat(archive);
|
|
161
|
+
const rel = relative(dir, archive);
|
|
162
|
+
if (rel.startsWith("..") || rel.startsWith(sep))
|
|
163
|
+
throw new Error(`${archive} is outside the repo directory ${dir}`);
|
|
164
|
+
return rel.split(sep).map(encodeURIComponent).join("/");
|
|
165
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, join, resolve } from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { buildRepo, emptyRepo, hashFile, readRepoJson, relativeUrl, upsertArchive, writeRepoJson } from "./build.js";
|
|
6
|
+
import { validateHosting } from "./hosting.js";
|
|
7
|
+
import { loadRepo } from "./load.js";
|
|
8
|
+
import { verifyArchive } from "./verify.js";
|
|
9
|
+
const USAGE = `gm-pantry init <dir> [--name NAME]
|
|
10
|
+
gm-pantry build <dir> [--shard-size N] [--prune]
|
|
11
|
+
gm-pantry add <dir> <archive> [--id ID] [--title TITLE] [--version LABEL]
|
|
12
|
+
gm-pantry validate <url|path> [--hosting]`;
|
|
13
|
+
async function main(argv) {
|
|
14
|
+
const { positionals: [cmd, target, archive], values: o } = parseArgs({
|
|
15
|
+
args: argv,
|
|
16
|
+
allowPositionals: true,
|
|
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
|
+
});
|
|
19
|
+
if (!cmd || !target)
|
|
20
|
+
return console.error(USAGE), 2;
|
|
21
|
+
const dir = resolve(target);
|
|
22
|
+
switch (cmd) {
|
|
23
|
+
case "init": {
|
|
24
|
+
if (await readRepoJson(dir))
|
|
25
|
+
return console.error("repo.json already exists"), 1;
|
|
26
|
+
await writeRepoJson(dir, emptyRepo(o.name ?? dir.split("/").pop()));
|
|
27
|
+
return console.log(`wrote ${join(dir, "repo.json")}`), 0;
|
|
28
|
+
}
|
|
29
|
+
case "build": {
|
|
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;
|
|
35
|
+
}
|
|
36
|
+
case "add": {
|
|
37
|
+
if (!archive)
|
|
38
|
+
return console.error(USAGE), 2;
|
|
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;
|
|
42
|
+
upsertArchive(repo, await relativeUrl(dir, resolve(archive)), await hashFile(resolve(archive)), o);
|
|
43
|
+
await writeRepoJson(dir, repo);
|
|
44
|
+
return console.log("repo.json updated"), 0;
|
|
45
|
+
}
|
|
46
|
+
case "validate":
|
|
47
|
+
return validate(target, o.hosting === true);
|
|
48
|
+
default:
|
|
49
|
+
return console.error(USAGE), 2;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function validate(target, hosting) {
|
|
53
|
+
let failed = false;
|
|
54
|
+
const isUrl = /^https?:\/\//.test(target);
|
|
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
|
+
};
|
|
72
|
+
}
|
|
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` : ""}`);
|
|
75
|
+
for (const w of warnings)
|
|
76
|
+
console.log(`warning ${w.path}: ${w.message}`), (failed = true);
|
|
77
|
+
if (!isUrl) {
|
|
78
|
+
for (const g of repo.games)
|
|
79
|
+
for (const v of g.versions) {
|
|
80
|
+
if (!v.file.url.startsWith(origin))
|
|
81
|
+
continue;
|
|
82
|
+
const path = join(dir, decodeURIComponent(v.file.url.slice(origin.length)));
|
|
83
|
+
try {
|
|
84
|
+
const r = await verifyArchive(await readFile(path), v.file);
|
|
85
|
+
if (!r.ok)
|
|
86
|
+
console.log(`error ${g.id} ${v.version}: ${r.reason} is ${r.actual}, repo.json says ${r.expected}`), (failed = true);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
console.log(`error ${g.id} ${v.version}: ${path} is missing`), (failed = true);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (hosting) {
|
|
94
|
+
if (!isUrl)
|
|
95
|
+
return console.error("--hosting needs a url"), 2;
|
|
96
|
+
const report = await validateHosting(target);
|
|
97
|
+
for (const c of report.checks)
|
|
98
|
+
console.log(`${c.status.padEnd(4)} ${c.name}: ${c.message}`);
|
|
99
|
+
if (!report.ok)
|
|
100
|
+
failed = true;
|
|
101
|
+
}
|
|
102
|
+
return failed ? 1 : 0;
|
|
103
|
+
}
|
|
104
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (e) => (console.error(e.message), process.exit(1)));
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface Check {
|
|
2
|
+
name: string;
|
|
3
|
+
status: "pass" | "warn" | "fail";
|
|
4
|
+
message: string;
|
|
5
|
+
}
|
|
6
|
+
export interface HostingReport {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
checks: Check[];
|
|
9
|
+
}
|
|
10
|
+
/** Fetches repo.json and the newest archive of the first game; checks CORS, Range, content types and validators. */
|
|
11
|
+
export declare function validateHosting(url: string, fetchImpl?: typeof fetch): Promise<HostingReport>;
|
package/dist/hosting.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// SPEC.md §2: does a URL actually serve a repo the way a browser consumer needs?
|
|
2
|
+
import { parseRepo, parseShard } from "./parse.js";
|
|
3
|
+
/** Fetches repo.json and the newest archive of the first game; checks CORS, Range, content types and validators. */
|
|
4
|
+
export async function validateHosting(url, fetchImpl = fetch) {
|
|
5
|
+
const checks = [];
|
|
6
|
+
const add = (name, status, message) => checks.push({ name, status, message });
|
|
7
|
+
const report = () => ({ ok: !checks.some((c) => c.status === "fail"), checks });
|
|
8
|
+
let res;
|
|
9
|
+
try {
|
|
10
|
+
res = await fetchImpl(url);
|
|
11
|
+
}
|
|
12
|
+
catch (e) {
|
|
13
|
+
add("repo.json", "fail", `fetch failed (no CORS or unreachable): ${e.message}`);
|
|
14
|
+
return report();
|
|
15
|
+
}
|
|
16
|
+
add("repo.json", res.ok ? "pass" : "fail", `HTTP ${res.status}`);
|
|
17
|
+
if (!res.ok)
|
|
18
|
+
return report();
|
|
19
|
+
cors(res, "repo.json cors", add);
|
|
20
|
+
const type = res.headers.get("content-type") ?? "";
|
|
21
|
+
add("repo.json content-type", /json|text\/plain/.test(type) ? "pass" : "warn", type || "missing");
|
|
22
|
+
const hasValidator = res.headers.has("etag") || res.headers.has("last-modified");
|
|
23
|
+
add("repo.json validators", hasValidator ? "pass" : "warn", hasValidator ? "ETag or Last-Modified present" : "no ETag or Last-Modified");
|
|
24
|
+
let archive;
|
|
25
|
+
try {
|
|
26
|
+
const { repo } = parseRepo(await res.json(), res.url || url);
|
|
27
|
+
archive = repo.games[0]?.versions[0]?.file.url;
|
|
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);
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
add("repo.json parses", "fail", e.message);
|
|
34
|
+
return report();
|
|
35
|
+
}
|
|
36
|
+
if (!archive)
|
|
37
|
+
return add("archive", "warn", "no game to sample"), report();
|
|
38
|
+
try {
|
|
39
|
+
const a = await fetchImpl(archive, { headers: { Range: "bytes=0-0" } });
|
|
40
|
+
await a.body?.cancel();
|
|
41
|
+
add("archive", a.ok ? "pass" : "fail", `HTTP ${a.status}`);
|
|
42
|
+
if (!a.ok)
|
|
43
|
+
return report();
|
|
44
|
+
cors(a, "archive cors", add);
|
|
45
|
+
add("archive range", a.status === 206 ? "pass" : "warn", a.status === 206 ? "206 Partial Content" : `Range ignored (HTTP ${a.status}); optional`);
|
|
46
|
+
const t = a.headers.get("content-type") ?? "";
|
|
47
|
+
add("archive content-type", /text\/html/.test(t) ? "fail" : "pass", t || "missing");
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
add("archive", "fail", `fetch failed (no CORS or unreachable): ${e.message}`);
|
|
51
|
+
}
|
|
52
|
+
return report();
|
|
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
|
+
}
|
|
70
|
+
function cors(res, name, add) {
|
|
71
|
+
const v = res.headers.get("access-control-allow-origin");
|
|
72
|
+
add(name, v === "*" ? "pass" : "fail", `Access-Control-Allow-Origin: ${v ?? "missing"}`);
|
|
73
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
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";
|
|
4
|
+
export { verifyArchive, type VerifyResult } from "./verify.ts";
|
|
5
|
+
export { validateHosting, type Check, type HostingReport } from "./hosting.ts";
|
package/dist/index.js
ADDED
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
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Game, Repo, Warning } from "./types.ts";
|
|
2
|
+
/** The caps of SPEC.md §4.4. */
|
|
3
|
+
export declare const LIMITS: {
|
|
4
|
+
readonly name: 80;
|
|
5
|
+
readonly repoDescription: 280;
|
|
6
|
+
readonly gamesPerFile: 2000;
|
|
7
|
+
readonly shards: 1000;
|
|
8
|
+
readonly id: 64;
|
|
9
|
+
readonly title: 200;
|
|
10
|
+
readonly versions: 200;
|
|
11
|
+
readonly versionLabel: 64;
|
|
12
|
+
readonly url: 2048;
|
|
13
|
+
readonly metaText: 2000;
|
|
14
|
+
readonly author: 120;
|
|
15
|
+
readonly notes: 2000;
|
|
16
|
+
readonly tags: 20;
|
|
17
|
+
readonly tag: 40;
|
|
18
|
+
readonly screenshots: 20;
|
|
19
|
+
readonly links: 20;
|
|
20
|
+
readonly linkName: 80;
|
|
21
|
+
readonly work: 8;
|
|
22
|
+
readonly workKey: 32;
|
|
23
|
+
readonly workText: 128;
|
|
24
|
+
};
|
|
25
|
+
export declare class RepoError extends Error {
|
|
26
|
+
}
|
|
27
|
+
/** Resolves a possibly relative url against the url of repo.json. Returns undefined if it is not a url. */
|
|
28
|
+
export declare function resolveUrl(url: string, baseUrl: string): string | undefined;
|
|
29
|
+
/** https, or http on a loopback host (so a repo can be tried locally). */
|
|
30
|
+
export declare function isSecureUrl(url: unknown): url is string;
|
|
31
|
+
/** Parses a repo.json already decoded from JSON. `baseUrl` is where it was fetched from. Throws RepoError. */
|
|
32
|
+
export declare function parseRepo(json: unknown, baseUrl: string): {
|
|
33
|
+
repo: Repo;
|
|
34
|
+
warnings: Warning[];
|
|
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
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// SPEC.md §4 and §5: reading a repo.json. Entries that are malformed are skipped and reported;
|
|
2
|
+
// advisory fields that are malformed are dropped and reported. Only a broken envelope throws.
|
|
3
|
+
/** The caps of SPEC.md §4.4. */
|
|
4
|
+
export const LIMITS = {
|
|
5
|
+
name: 80,
|
|
6
|
+
repoDescription: 280,
|
|
7
|
+
gamesPerFile: 2000,
|
|
8
|
+
shards: 1000,
|
|
9
|
+
id: 64,
|
|
10
|
+
title: 200,
|
|
11
|
+
versions: 200,
|
|
12
|
+
versionLabel: 64,
|
|
13
|
+
url: 2048,
|
|
14
|
+
metaText: 2000,
|
|
15
|
+
author: 120,
|
|
16
|
+
notes: 2000,
|
|
17
|
+
tags: 20,
|
|
18
|
+
tag: 40,
|
|
19
|
+
screenshots: 20,
|
|
20
|
+
links: 20,
|
|
21
|
+
linkName: 80,
|
|
22
|
+
work: 8,
|
|
23
|
+
workKey: 32,
|
|
24
|
+
workText: 128,
|
|
25
|
+
};
|
|
26
|
+
export class RepoError extends Error {
|
|
27
|
+
}
|
|
28
|
+
const ID = /^[a-z0-9][a-z0-9._-]*$/;
|
|
29
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
30
|
+
const WORK_KEY = /^[a-z][a-z0-9_-]*$/;
|
|
31
|
+
const DATE = /^\d{4}-\d{2}-\d{2}(T[\d:.]+(Z|[+-]\d{2}:\d{2}))?$/;
|
|
32
|
+
const FORMATS = ["zip", "7z", "rar"];
|
|
33
|
+
/** Resolves a possibly relative url against the url of repo.json. Returns undefined if it is not a url. */
|
|
34
|
+
export function resolveUrl(url, baseUrl) {
|
|
35
|
+
try {
|
|
36
|
+
return new URL(url, baseUrl).href;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** https, or http on a loopback host (so a repo can be tried locally). */
|
|
43
|
+
export function isSecureUrl(url) {
|
|
44
|
+
if (typeof url !== "string" || url.length > LIMITS.url)
|
|
45
|
+
return false;
|
|
46
|
+
try {
|
|
47
|
+
const u = new URL(url);
|
|
48
|
+
return u.protocol === "https:" ||
|
|
49
|
+
(u.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
56
|
+
const text = (v, max) => typeof v === "string" && v.trim() !== "" && v.trim().length <= max ? v.trim() : undefined;
|
|
57
|
+
const validDate = (v) => typeof v === "string" && DATE.test(v) && !isNaN(Date.parse(v));
|
|
58
|
+
/** Parses a repo.json already decoded from JSON. `baseUrl` is where it was fetched from. Throws RepoError. */
|
|
59
|
+
export function parseRepo(json, baseUrl) {
|
|
60
|
+
const warnings = [];
|
|
61
|
+
const warn = (path, message) => warnings.push({ path, message });
|
|
62
|
+
if (!isObject(json))
|
|
63
|
+
throw new RepoError("repo.json is not an object");
|
|
64
|
+
if (json.schema !== 1)
|
|
65
|
+
throw new RepoError(`unsupported schema ${JSON.stringify(json.schema)}; this reader implements 1`);
|
|
66
|
+
const name = text(json.name, LIMITS.name);
|
|
67
|
+
if (name === undefined)
|
|
68
|
+
throw new RepoError(`"name" must be a non-empty string of at most ${LIMITS.name} characters`);
|
|
69
|
+
if (!validDate(json.updated))
|
|
70
|
+
throw new RepoError(`"updated" must be an ISO-8601 date or timestamp`);
|
|
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))
|
|
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`);
|
|
77
|
+
const repo = { schema: 1, name, updated: json.updated, games: [] };
|
|
78
|
+
const description = text(json.description, LIMITS.repoDescription);
|
|
79
|
+
if (description)
|
|
80
|
+
repo.description = description;
|
|
81
|
+
else if (json.description !== undefined)
|
|
82
|
+
warn("description", "dropped: not a string of 1-280 characters");
|
|
83
|
+
if (isSecureUrl(json.homepage))
|
|
84
|
+
repo.homepage = json.homepage;
|
|
85
|
+
else if (json.homepage !== undefined)
|
|
86
|
+
warn("homepage", "dropped: not an https url");
|
|
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) => {
|
|
113
|
+
const path = `games[${i}]`;
|
|
114
|
+
const game = parseGame(entry, path, baseUrl, warn);
|
|
115
|
+
if (!game)
|
|
116
|
+
return;
|
|
117
|
+
if (seen.has(game.id))
|
|
118
|
+
return warn(path, `skipped: duplicate id "${game.id}"`);
|
|
119
|
+
seen.add(game.id);
|
|
120
|
+
games.push(game);
|
|
121
|
+
});
|
|
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;
|
|
142
|
+
}
|
|
143
|
+
function parseGame(entry, path, baseUrl, warn) {
|
|
144
|
+
if (!isObject(entry))
|
|
145
|
+
return void warn(path, "skipped: not an object");
|
|
146
|
+
if (typeof entry.id !== "string" || entry.id.length > LIMITS.id || !ID.test(entry.id)) {
|
|
147
|
+
return void warn(path, `skipped: "id" must match ${ID} and be at most ${LIMITS.id} characters`);
|
|
148
|
+
}
|
|
149
|
+
const title = text(entry.title, LIMITS.title);
|
|
150
|
+
if (title === undefined)
|
|
151
|
+
return void warn(path, `skipped: "title" must be a non-empty string of at most ${LIMITS.title} characters`);
|
|
152
|
+
if (!Array.isArray(entry.versions))
|
|
153
|
+
return void warn(path, `skipped: "versions" must be an array`);
|
|
154
|
+
const versions = [];
|
|
155
|
+
entry.versions.slice(0, LIMITS.versions).forEach((v, i) => {
|
|
156
|
+
const version = parseVersion(v, `${path}.versions[${i}]`, baseUrl, warn);
|
|
157
|
+
if (version)
|
|
158
|
+
versions.push(version);
|
|
159
|
+
});
|
|
160
|
+
if (versions.length === 0)
|
|
161
|
+
return void warn(path, "skipped: no valid version");
|
|
162
|
+
const game = { id: entry.id, title, versions };
|
|
163
|
+
const work = parseWork(entry.work);
|
|
164
|
+
if (work)
|
|
165
|
+
game.work = work;
|
|
166
|
+
else if (entry.work !== undefined)
|
|
167
|
+
warn(`${path}.work`, "dropped: not an object of short string or integer values");
|
|
168
|
+
if (entry.meta !== undefined)
|
|
169
|
+
game.meta = parseMeta(entry.meta, `${path}.meta`, baseUrl, warn);
|
|
170
|
+
return game;
|
|
171
|
+
}
|
|
172
|
+
function parseVersion(v, path, baseUrl, warn) {
|
|
173
|
+
if (!isObject(v))
|
|
174
|
+
return void warn(path, "skipped: not an object");
|
|
175
|
+
const label = text(v.version, LIMITS.versionLabel);
|
|
176
|
+
if (label === undefined)
|
|
177
|
+
return void warn(path, `skipped: "version" must be a non-empty string of at most ${LIMITS.versionLabel} characters`);
|
|
178
|
+
const f = v.file;
|
|
179
|
+
if (!isObject(f))
|
|
180
|
+
return void warn(path, `skipped: "file" must be an object`);
|
|
181
|
+
if (typeof f.url !== "string" || f.url.length > LIMITS.url)
|
|
182
|
+
return void warn(path, `skipped: "file.url" must be a url`);
|
|
183
|
+
const url = resolveUrl(f.url, baseUrl);
|
|
184
|
+
if (url === undefined || !isSecureUrl(url))
|
|
185
|
+
return void warn(path, `skipped: "file.url" must resolve to an https url`);
|
|
186
|
+
if (typeof f.sha256 !== "string" || !SHA256.test(f.sha256))
|
|
187
|
+
return void warn(path, `skipped: "file.sha256" must be 64 lowercase hex digits`);
|
|
188
|
+
if (!Number.isSafeInteger(f.size) || f.size < 0)
|
|
189
|
+
return void warn(path, `skipped: "file.size" must be a non-negative integer`);
|
|
190
|
+
const file = { url, sha256: f.sha256, size: f.size };
|
|
191
|
+
const version = { version: label, file };
|
|
192
|
+
if (typeof v.format === "string" && FORMATS.includes(v.format))
|
|
193
|
+
version.format = v.format;
|
|
194
|
+
else if (v.format !== undefined)
|
|
195
|
+
warn(`${path}.format`, "dropped: not zip, 7z or rar");
|
|
196
|
+
if (typeof v.build === "string" && SHA256.test(v.build))
|
|
197
|
+
version.build = v.build;
|
|
198
|
+
else if (v.build !== undefined)
|
|
199
|
+
warn(`${path}.build`, "dropped: not 64 lowercase hex digits");
|
|
200
|
+
const notes = text(v.notes, LIMITS.notes);
|
|
201
|
+
if (notes)
|
|
202
|
+
version.notes = notes;
|
|
203
|
+
else if (v.notes !== undefined)
|
|
204
|
+
warn(`${path}.notes`, "dropped");
|
|
205
|
+
if (validDate(v.released))
|
|
206
|
+
version.released = v.released;
|
|
207
|
+
else if (v.released !== undefined)
|
|
208
|
+
warn(`${path}.released`, "dropped: not an ISO-8601 date");
|
|
209
|
+
return version;
|
|
210
|
+
}
|
|
211
|
+
function parseWork(w) {
|
|
212
|
+
if (!isObject(w))
|
|
213
|
+
return undefined;
|
|
214
|
+
const entries = Object.entries(w);
|
|
215
|
+
if (entries.length === 0 || entries.length > LIMITS.work)
|
|
216
|
+
return undefined;
|
|
217
|
+
const out = {};
|
|
218
|
+
for (const [k, val] of entries) {
|
|
219
|
+
if (k.length > LIMITS.workKey || !WORK_KEY.test(k))
|
|
220
|
+
return undefined;
|
|
221
|
+
if (typeof val === "number" ? !Number.isSafeInteger(val) : text(val, LIMITS.workText) === undefined)
|
|
222
|
+
return undefined;
|
|
223
|
+
out[k] = typeof val === "string" ? val.trim() : val;
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
function parseMeta(m, path, baseUrl, warn) {
|
|
228
|
+
if (!isObject(m))
|
|
229
|
+
return void warn(path, "dropped: not an object");
|
|
230
|
+
const meta = {};
|
|
231
|
+
const url = (v) => {
|
|
232
|
+
const resolved = typeof v === "string" ? resolveUrl(v, baseUrl) : undefined;
|
|
233
|
+
return resolved !== undefined && isSecureUrl(resolved) ? resolved : undefined;
|
|
234
|
+
};
|
|
235
|
+
const author = text(m.author, LIMITS.author);
|
|
236
|
+
if (author)
|
|
237
|
+
meta.author = author;
|
|
238
|
+
const description = text(m.description, LIMITS.metaText);
|
|
239
|
+
if (description)
|
|
240
|
+
meta.description = description;
|
|
241
|
+
if (Array.isArray(m.tags)) {
|
|
242
|
+
const tags = m.tags.map((t) => text(t, LIMITS.tag)).filter((t) => t !== undefined).slice(0, LIMITS.tags);
|
|
243
|
+
if (tags.length)
|
|
244
|
+
meta.tags = tags;
|
|
245
|
+
}
|
|
246
|
+
const cover = url(m.cover);
|
|
247
|
+
if (cover)
|
|
248
|
+
meta.cover = cover;
|
|
249
|
+
if (Array.isArray(m.screenshots)) {
|
|
250
|
+
const shots = m.screenshots.map(url).filter((u) => u !== undefined).slice(0, LIMITS.screenshots);
|
|
251
|
+
if (shots.length)
|
|
252
|
+
meta.screenshots = shots;
|
|
253
|
+
}
|
|
254
|
+
if (Array.isArray(m.links)) {
|
|
255
|
+
const links = m.links.flatMap((l) => {
|
|
256
|
+
const name = isObject(l) ? text(l.name, LIMITS.linkName) : undefined;
|
|
257
|
+
const href = isObject(l) ? url(l.url) : undefined;
|
|
258
|
+
return name && href ? [{ name, url: href }] : [];
|
|
259
|
+
}).slice(0, LIMITS.links);
|
|
260
|
+
if (links.length)
|
|
261
|
+
meta.links = links;
|
|
262
|
+
}
|
|
263
|
+
return Object.keys(meta).length ? meta : undefined;
|
|
264
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export type Format = "zip" | "7z" | "rar";
|
|
2
|
+
export interface ArchiveFile {
|
|
3
|
+
url: string;
|
|
4
|
+
/** Lowercase hex SHA-256 of the archive, exactly as stored. */
|
|
5
|
+
sha256: string;
|
|
6
|
+
/** Bytes. */
|
|
7
|
+
size: number;
|
|
8
|
+
}
|
|
9
|
+
export interface Version {
|
|
10
|
+
version: string;
|
|
11
|
+
file: ArchiveFile;
|
|
12
|
+
format?: Format;
|
|
13
|
+
/** Claimed hash of the normalised game payload; a hint, consumers recompute. */
|
|
14
|
+
build?: string;
|
|
15
|
+
notes?: string;
|
|
16
|
+
/** YYYY-MM-DD, or a full ISO-8601 timestamp. */
|
|
17
|
+
released?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface Meta {
|
|
20
|
+
author?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
tags?: string[];
|
|
23
|
+
cover?: string;
|
|
24
|
+
screenshots?: string[];
|
|
25
|
+
links?: {
|
|
26
|
+
name: string;
|
|
27
|
+
url: string;
|
|
28
|
+
}[];
|
|
29
|
+
}
|
|
30
|
+
export interface Game {
|
|
31
|
+
id: string;
|
|
32
|
+
title: string;
|
|
33
|
+
work?: Record<string, string | number>;
|
|
34
|
+
meta?: Meta;
|
|
35
|
+
/** Non-empty, newest first. */
|
|
36
|
+
versions: Version[];
|
|
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
|
+
}
|
|
48
|
+
export interface Repo {
|
|
49
|
+
schema: 1;
|
|
50
|
+
name: string;
|
|
51
|
+
description?: string;
|
|
52
|
+
homepage?: string;
|
|
53
|
+
updated: string;
|
|
54
|
+
/** Inline games; with `shards`, these come first. */
|
|
55
|
+
games: Game[];
|
|
56
|
+
shards?: Shard[];
|
|
57
|
+
}
|
|
58
|
+
/** Something skipped or dropped while parsing; `path` is a JSON path such as `games[3].versions[0]`. */
|
|
59
|
+
export interface Warning {
|
|
60
|
+
path: string;
|
|
61
|
+
message: string;
|
|
62
|
+
}
|
package/dist/types.js
ADDED
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ArchiveFile } from "./types.ts";
|
|
2
|
+
export type VerifyResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
reason: "size" | "sha256";
|
|
7
|
+
expected: string | number;
|
|
8
|
+
actual: string | number;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Checks bytes against a `file` entry: size first, then sha256. A consumer discards the archive on `ok: false`.
|
|
12
|
+
* shortcut: a stream is buffered whole (Web Crypto has no incremental digest), upgrade when archives outgrow memory.
|
|
13
|
+
*/
|
|
14
|
+
export declare function verifyArchive(data: Uint8Array | ArrayBuffer | ReadableStream<Uint8Array>, file: Pick<ArchiveFile, "sha256" | "size">): Promise<VerifyResult>;
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// SPEC.md §6: the sha256 check on a downloaded archive. Web Crypto only, so it runs in a browser.
|
|
2
|
+
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
3
|
+
/**
|
|
4
|
+
* Checks bytes against a `file` entry: size first, then sha256. A consumer discards the archive on `ok: false`.
|
|
5
|
+
* shortcut: a stream is buffered whole (Web Crypto has no incremental digest), upgrade when archives outgrow memory.
|
|
6
|
+
*/
|
|
7
|
+
export async function verifyArchive(data, file) {
|
|
8
|
+
const bytes = data instanceof ReadableStream
|
|
9
|
+
? new Uint8Array(await new Response(data).arrayBuffer())
|
|
10
|
+
: new Uint8Array(data);
|
|
11
|
+
if (bytes.byteLength !== file.size)
|
|
12
|
+
return { ok: false, reason: "size", expected: file.size, actual: bytes.byteLength };
|
|
13
|
+
const actual = hex(await crypto.subtle.digest("SHA-256", bytes));
|
|
14
|
+
return actual === file.sha256 ? { ok: true } : { ok: false, reason: "sha256", expected: file.sha256, actual };
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1 +1,50 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "gm-pantry",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Game repositories: static directories of game archives with a repo.json, plus a parser, validator and CLI.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"gamemaker",
|
|
7
|
+
"repository",
|
|
8
|
+
"archive",
|
|
9
|
+
"static"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/QuentinJanuel/GM-Pantry#readme",
|
|
12
|
+
"bugs": "https://github.com/QuentinJanuel/GM-Pantry/issues",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/QuentinJanuel/GM-Pantry.git"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"type": "module",
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"main": "dist/index.js",
|
|
24
|
+
"types": "dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"bin": {
|
|
32
|
+
"gm-pantry": "dist/cli.js"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc -p tsconfig.build.json",
|
|
39
|
+
"typecheck": "tsc",
|
|
40
|
+
"test": "node --test test/*.test.ts scripts/*.test.mjs",
|
|
41
|
+
"prepublishOnly": "npm run build"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^24.0.0",
|
|
45
|
+
"typescript": "^5.9.0"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=24"
|
|
49
|
+
}
|
|
50
|
+
}
|