gm-pantry 0.2.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/README.md +95 -2
- package/dist/build.d.ts +3 -20
- package/dist/build.js +17 -87
- package/dist/builder.d.ts +68 -0
- package/dist/builder.js +139 -0
- package/dist/cli.js +91 -7
- package/dist/hash.d.ts +15 -0
- package/dist/hash.js +31 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/s3.d.ts +177 -0
- package/dist/s3.js +425 -0
- package/dist/verify.d.ts +3 -2
- package/dist/verify.js +17 -11
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -40,6 +40,8 @@ npx gm-pantry init <dir> [--name NAME]
|
|
|
40
40
|
npx gm-pantry build <dir> [--shard-size N] [--prune]
|
|
41
41
|
npx gm-pantry add <dir> <archive> [--id ID] [--title TITLE] [--version LABEL]
|
|
42
42
|
npx gm-pantry validate <url|path> [--hosting]
|
|
43
|
+
npx gm-pantry publish <dir> --endpoint URL --bucket NAME --region R [--prefix P] [--shard-size N] [--path-style|--virtual-host]
|
|
44
|
+
npx gm-pantry cors --endpoint URL --bucket NAME --region R [--prefix P] [--origin O] [--public-url URL] [--apply]
|
|
43
45
|
```
|
|
44
46
|
|
|
45
47
|
- `build` hashes every `.zip`, `.7z` and `.rar` at the top of the folder (streaming), derives each
|
|
@@ -56,6 +58,15 @@ npx gm-pantry validate <url|path> [--hosting]
|
|
|
56
58
|
folder it also loads every shard and checks each archive's hash. `--hosting` (URL only) fetches `repo.json` and a sample
|
|
57
59
|
archive and checks CORS, Range, content types and validators. Exit code 1 on any problem.
|
|
58
60
|
|
|
61
|
+
- `publish` builds the folder as `build` does, then uploads to S3-compatible storage: archives (multipart
|
|
62
|
+
above 16 MiB, an archive the bucket already holds with the same size and hash is skipped), new shards,
|
|
63
|
+
and `repo.json` last, then deletes shards nothing lists any more. Credentials come from
|
|
64
|
+
`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` (and `AWS_SESSION_TOKEN`) or `--access-key-id` /
|
|
65
|
+
`--secret-access-key` / `--session-token`. It checks the connection first and reports bad
|
|
66
|
+
credentials, a wrong region, a missing bucket or missing permissions separately.
|
|
67
|
+
- `cors` prints the CORS configuration to paste (below). With `--public-url` it also checks what a
|
|
68
|
+
browser would see; with `--apply` it calls `PutBucketCors` for you (needs a key allowed to).
|
|
69
|
+
|
|
59
70
|
## Library
|
|
60
71
|
|
|
61
72
|
```ts
|
|
@@ -68,8 +79,90 @@ const { repo, warnings } = await loadRepo(url, { concurrency: 4, onProgress: (do
|
|
|
68
79
|
const result = await verifyArchive(bytes, repo.games[0].versions[0].file); // { ok: true } or { ok: false, ... }
|
|
69
80
|
```
|
|
70
81
|
|
|
71
|
-
The library uses Web Crypto
|
|
72
|
-
APIs.
|
|
82
|
+
The library uses Web Crypto, `fetch` and [hash-wasm](https://github.com/Daninet/hash-wasm) (streaming
|
|
83
|
+
SHA-256), so it runs in browsers and Node. Only the CLI uses Node APIs.
|
|
84
|
+
|
|
85
|
+
### Editing a repo in memory and hashing big files
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { addVersion, emptyRepo, hashBlob, inlineShards, removeGame, removeVersion, serialize, updateGame } from "gm-pantry";
|
|
89
|
+
|
|
90
|
+
const { sha256, size } = await hashBlob(file, { onProgress: (bytes) => {}, signal }); // streams: a 4 GB File is fine
|
|
91
|
+
const repo = emptyRepo("My Pantry"); // or the raw repo.json, with inlineShards() when it is sharded
|
|
92
|
+
addVersion(repo, { title: "My Game", version: "1.0", file: { url: "My_Game.zip", sha256, size } });
|
|
93
|
+
updateGame(repo, "my-game", { meta: { author: "Me" }, work: { delfruit: 1234 } }); // null removes a key
|
|
94
|
+
removeVersion(repo, "my-game", "0.9"); removeGame(repo, "old-game");
|
|
95
|
+
const { repoJson, shards, orphans } = await serialize(repo, { shardSize: 1000, previous }); // previous: the repo.json being replaced
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`serialize` gives the bytes of `repo.json`, the shard files to write (`shards/<sha16>.json`) and the shards
|
|
99
|
+
the previous `repo.json` listed that are now unused. The same input always gives the same bytes. The
|
|
100
|
+
CLI's `build` and `add` use these functions. `verifyArchive` also accepts a `Blob` or stream and hashes it
|
|
101
|
+
chunk by chunk.
|
|
102
|
+
|
|
103
|
+
### Publishing to S3-compatible storage from a browser (`gm-pantry/s3`)
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { S3Store, publishRepo, checkCors } from "gm-pantry/s3";
|
|
107
|
+
|
|
108
|
+
const store = new S3Store({ endpoint, region, bucket, accessKeyId, secretAccessKey, sessionToken, prefix: "games", forcePathStyle });
|
|
109
|
+
const test = await store.testConnection(); // list, write, delete a probe object; bad credentials, region, bucket and permissions are told apart
|
|
110
|
+
const loaded = await store.getObject("repo.json"); // { bytes, etag } or undefined; keep the etag
|
|
111
|
+
const result = await publishRepo(store, { repoJson, shards, orphans }, {
|
|
112
|
+
archives: [{ url: "My_Game.zip", body: file, sha256 }], // uploaded first, multipart when large
|
|
113
|
+
ifMatch: loaded?.etag ?? null, // refuses with ConflictError if repo.json changed since
|
|
114
|
+
resume: (url) => saved.get(url), onState: (url, state) => saved.set(url, state), // persist the multipart state
|
|
115
|
+
onProgress: (url, done, total) => {},
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`publishRepo` uploads archives and new shards first and `repo.json` last (`Content-Type: application/json`,
|
|
120
|
+
`Cache-Control: no-cache`; shards get `public, max-age=31536000, immutable`), writes it with `If-Match` /
|
|
121
|
+
`If-None-Match: *`, and deletes unreferenced shards only afterwards. If the service rejects conditional
|
|
122
|
+
writes (`501`), it writes unconditionally and returns `conditional: false`: with several publishers,
|
|
123
|
+
the last writer wins. `store.uploadFile(key, blob, { partSize, concurrency, onProgress, signal, onState, resume })`
|
|
124
|
+
is the multipart upload on its own: parts default to 16 MiB and grow so any file fits in 10 000
|
|
125
|
+
parts, three are in flight at once, and an upload stopped by `signal` or a crash continues from
|
|
126
|
+
`resume`. `store.abortUpload` and `store.abortIncompleteUploads()` discard unfinished uploads. Requests are signed
|
|
127
|
+
with SigV4 by [aws4fetch](https://github.com/mhart/aws4fetch); archive bodies are sent with
|
|
128
|
+
`UNSIGNED-PAYLOAD`, so use HTTPS.
|
|
129
|
+
|
|
130
|
+
### CORS: set it once, outside the browser
|
|
131
|
+
|
|
132
|
+
A browser can only call the bucket after the bucket allows it, and `PutBucketCors` is itself a
|
|
133
|
+
cross-origin request, so a web page cannot set the first rule. The owner sets it once with the
|
|
134
|
+
provider's tools (or `gm-pantry cors --apply`, or `store.setCors()` from Node or a desktop app, where
|
|
135
|
+
CORS is not enforced). `checkCors(publicBaseUrl, origin, { writeUrl })` runs the checks a browser would
|
|
136
|
+
(read `repo.json` with CORS, `ETag` exposed, `Range`, and preflights for the `PUT`, `POST` and `DELETE`
|
|
137
|
+
with the headers the publisher sends) and returns what is missing plus these snippets
|
|
138
|
+
(`corsSnippets(origin)`; `origin` is the web app, or `*`):
|
|
139
|
+
|
|
140
|
+
- **Amazon S3**: save as `cors.json`, then `aws s3api put-bucket-cors --bucket BUCKET --cors-configuration file://cors.json`
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{ "CORSRules": [{ "AllowedOrigins": ["*"], "AllowedMethods": ["GET", "HEAD", "PUT", "POST", "DELETE"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag", "Content-Length", "Content-Range"], "MaxAgeSeconds": 3600 }] }
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
- **Cloudflare R2**: paste into the bucket's Settings, CORS policy in the dashboard:
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
[{ "AllowedOrigins": ["*"], "AllowedMethods": ["GET", "HEAD", "PUT", "POST", "DELETE"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag", "Content-Length", "Content-Range"], "MaxAgeSeconds": 3600 }]
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
or, with wrangler, `npx wrangler r2 bucket cors set BUCKET --file cors.json` and this file:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{ "rules": [{ "allowed": { "origins": ["*"], "methods": ["GET", "HEAD", "PUT", "POST", "DELETE"], "headers": ["*"] }, "exposeHeaders": ["ETag", "Content-Length", "Content-Range"], "maxAgeSeconds": 3600 }] }
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
- **Backblaze B2**: its S3 API accepts the S3 rule above; with the B2 CLI,
|
|
159
|
+
`b2 bucket update --cors-rules '<json>' BUCKET allPrivate`, where the JSON is
|
|
160
|
+
`[{"corsRuleName":"gm-pantry","allowedOrigins":["*"],"allowedOperations":["s3_get","s3_head","s3_put","s3_post","s3_delete"],"allowedHeaders":["*"],"exposeHeaders":["ETag","Content-Length","Content-Range"],"maxAgeSeconds":3600}]`.
|
|
161
|
+
(`allPrivate` is the bucket type: use your bucket's current one.)
|
|
162
|
+
|
|
163
|
+
For publishing, prefer your web app's origin over `*`. For readers only, the `GET`/`HEAD` rule further
|
|
164
|
+
down is enough. These snippets were written from the providers' documentation and checked against a
|
|
165
|
+
fake S3, not against the real services.
|
|
73
166
|
|
|
74
167
|
## Hosting
|
|
75
168
|
|
package/dist/build.d.ts
CHANGED
|
@@ -1,30 +1,14 @@
|
|
|
1
|
-
type Json
|
|
1
|
+
import { type Json } from "./builder.ts";
|
|
2
2
|
export declare const ARCHIVE_EXTENSIONS: string[];
|
|
3
3
|
export declare function hashFile(path: string): Promise<{
|
|
4
4
|
sha256: string;
|
|
5
5
|
size: number;
|
|
6
6
|
}>;
|
|
7
|
-
export declare const slug: (s: string) => string;
|
|
8
7
|
export declare function readRepoJson(dir: string): Promise<Json | undefined>;
|
|
9
|
-
|
|
10
|
-
|
|
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>;
|
|
8
|
+
/** Writes repo.json unless it already holds exactly these bytes; true when it changed. */
|
|
9
|
+
export declare function writeRepoJson(dir: string, repoJson: string): Promise<boolean>;
|
|
21
10
|
/** Every archive in `dir` (top level only), by name, as repo-relative urls. */
|
|
22
11
|
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
12
|
export interface BuildOptions {
|
|
29
13
|
/** Games per shard; at or below this many games the output stays inline. Default 1000. */
|
|
30
14
|
shardSize?: number;
|
|
@@ -39,4 +23,3 @@ export declare function buildRepo(dir: string, name?: string, now?: string, { sh
|
|
|
39
23
|
}>;
|
|
40
24
|
/** The repo-relative url of an archive that must live inside the repo directory. */
|
|
41
25
|
export declare function relativeUrl(dir: string, archive: string): Promise<string>;
|
|
42
|
-
export {};
|
package/dist/build.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Works on the raw JSON, never on a parsed Repo, so hand-edited fields and unknown fields survive.
|
|
1
|
+
// Building a repo.json from a folder (SPEC.md §3.7): node-only, kept out of the library entry point. The editing and serialising live in builder.ts.
|
|
3
2
|
import { createHash } from "node:crypto";
|
|
4
3
|
import { createReadStream } from "node:fs";
|
|
5
4
|
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
6
5
|
import { basename, extname, join, relative, sep } from "node:path";
|
|
7
|
-
import {
|
|
6
|
+
import { emptyRepo, inlineShards, serialize, upsertArchive } from "./builder.js";
|
|
8
7
|
export const ARCHIVE_EXTENSIONS = [".zip", ".7z", ".rar"];
|
|
9
8
|
export async function hashFile(path) {
|
|
10
9
|
const hash = createHash("sha256");
|
|
@@ -15,12 +14,8 @@ export async function hashFile(path) {
|
|
|
15
14
|
}
|
|
16
15
|
return { sha256: hash.digest("hex"), size };
|
|
17
16
|
}
|
|
18
|
-
const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
19
17
|
const SHARD_FILE = /^[0-9a-f]{16}\.json$/;
|
|
20
18
|
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
19
|
export async function readRepoJson(dir) {
|
|
25
20
|
try {
|
|
26
21
|
return JSON.parse(await readFile(join(dir, "repo.json"), "utf8"));
|
|
@@ -31,46 +26,11 @@ export async function readRepoJson(dir) {
|
|
|
31
26
|
throw new Error(`repo.json in ${dir} is unreadable: ${e.message}`);
|
|
32
27
|
}
|
|
33
28
|
}
|
|
34
|
-
|
|
35
|
-
|
|
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)
|
|
29
|
+
/** Writes repo.json unless it already holds exactly these bytes; true when it changed. */
|
|
30
|
+
export async function writeRepoJson(dir, repoJson) {
|
|
31
|
+
if (repoJson === (await readFile(join(dir, "repo.json"), "utf8").catch(() => undefined)))
|
|
72
32
|
return false;
|
|
73
|
-
await writeFile(join(dir, "repo.json"),
|
|
33
|
+
await writeFile(join(dir, "repo.json"), repoJson);
|
|
74
34
|
return true;
|
|
75
35
|
}
|
|
76
36
|
/** Every archive in `dir` (top level only), by name, as repo-relative urls. */
|
|
@@ -80,35 +40,13 @@ export async function listArchives(dir) {
|
|
|
80
40
|
.map((e) => e.name)
|
|
81
41
|
.sort();
|
|
82
42
|
}
|
|
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
43
|
/** Moves the games of a sharded repo.json inline, so build can edit one flat list. */
|
|
99
|
-
async function
|
|
44
|
+
async function inline(dir, repo) {
|
|
100
45
|
for (const shard of repo.shards ?? []) {
|
|
101
46
|
if (!isRelative(shard.url))
|
|
102
47
|
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
48
|
}
|
|
111
|
-
|
|
49
|
+
await inlineShards(repo, (url) => readFile(join(dir, decodeURIComponent(url)), "utf8"));
|
|
112
50
|
}
|
|
113
51
|
/** Drops versions whose relative archive url no longer exists in `dir`, then games left with none. */
|
|
114
52
|
async function pruneMissing(dir, repo) {
|
|
@@ -126,28 +64,20 @@ async function pruneMissing(dir, repo) {
|
|
|
126
64
|
return before - repo.games.length;
|
|
127
65
|
}
|
|
128
66
|
export async function buildRepo(dir, name, now, { shardSize = 1000, prune = false } = {}) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const repo = (await readRepoJson(dir)) ?? emptyRepo(name ?? basename(dir));
|
|
133
|
-
await inlineShards(dir, repo);
|
|
67
|
+
const repo = (await readRepoJson(dir)) ?? emptyRepo(name ?? basename(dir), now);
|
|
68
|
+
const previous = structuredClone(repo);
|
|
69
|
+
await inline(dir, repo);
|
|
134
70
|
const files = await listArchives(dir);
|
|
135
71
|
for (const f of files)
|
|
136
72
|
upsertArchive(repo, encodeURIComponent(f), await hashFile(join(dir, f)));
|
|
137
73
|
const pruned = prune ? await pruneMissing(dir, repo) : 0;
|
|
138
|
-
|
|
139
|
-
|
|
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`);
|
|
74
|
+
const out = await serialize(repo, { shardSize, previous, now });
|
|
75
|
+
if (out.shards.size)
|
|
144
76
|
await mkdir(join(dir, "shards"), { recursive: true });
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
const changed = await writeRepoJson(dir, out, now);
|
|
77
|
+
for (const [url, content] of out.shards)
|
|
78
|
+
await writeFile(join(dir, url), content);
|
|
79
|
+
const written = out.shards;
|
|
80
|
+
const changed = await writeRepoJson(dir, out.repoJson);
|
|
151
81
|
// Only files shaped like ours are touched; anything else in shards/ is the owner's.
|
|
152
82
|
for (const f of await readdir(join(dir, "shards")).catch(() => [])) {
|
|
153
83
|
if (SHARD_FILE.test(f) && !written.has(`shards/${f}`))
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export type Json = Record<string, any>;
|
|
2
|
+
export declare const slug: (s: string) => string;
|
|
3
|
+
/** "dir/My_Game-v2.zip" -> "My Game-v2" */
|
|
4
|
+
export declare const titleOf: (file: string) => string;
|
|
5
|
+
export declare function emptyRepo(name: string, now?: string): Json;
|
|
6
|
+
export interface NewVersion {
|
|
7
|
+
/** Game id; defaults to the slug of `title`. */
|
|
8
|
+
id?: string;
|
|
9
|
+
/** Required when the game does not exist yet. */
|
|
10
|
+
title?: string;
|
|
11
|
+
work?: Record<string, string | number>;
|
|
12
|
+
meta?: Json;
|
|
13
|
+
/** Version label; defaults to the first unused number. */
|
|
14
|
+
version?: string;
|
|
15
|
+
notes?: string;
|
|
16
|
+
released?: string;
|
|
17
|
+
format?: string;
|
|
18
|
+
file: {
|
|
19
|
+
url: string;
|
|
20
|
+
sha256: string;
|
|
21
|
+
size: number;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** Adds a version (newest first) to its game, creating the game if needed; a version already pointing at `file.url` just gets its hash and size refreshed. */
|
|
25
|
+
export declare function addVersion(repo: Json, v: NewVersion): Json;
|
|
26
|
+
/** Adds an archive by its url, deriving the game id and title from the file name. */
|
|
27
|
+
export declare function upsertArchive(repo: Json, url: string, hashed: {
|
|
28
|
+
sha256: string;
|
|
29
|
+
size: number;
|
|
30
|
+
}, opts?: {
|
|
31
|
+
id?: string;
|
|
32
|
+
title?: string;
|
|
33
|
+
version?: string;
|
|
34
|
+
}): void;
|
|
35
|
+
/** Drops one version; a game left with none is dropped too. False when nothing matched. */
|
|
36
|
+
export declare function removeVersion(repo: Json, gameId: string, version: string): boolean;
|
|
37
|
+
export declare function removeGame(repo: Json, gameId: string): boolean;
|
|
38
|
+
export interface GamePatch {
|
|
39
|
+
title?: string;
|
|
40
|
+
/** Merged key by key; a key set to null is removed, `null` for the whole object removes it. */
|
|
41
|
+
work?: Record<string, string | number | null> | null;
|
|
42
|
+
meta?: Json | null;
|
|
43
|
+
}
|
|
44
|
+
export declare function updateGame(repo: Json, gameId: string, patch: GamePatch): Json;
|
|
45
|
+
/** Moves the games of a sharded repo.json inline, so one flat list can be edited. `readShard` returns a shard file's text by its (repo-relative) url. */
|
|
46
|
+
export declare function inlineShards(repo: Json, readShard: (url: string) => Promise<string>): Promise<void>;
|
|
47
|
+
/** Splits games, sorted by id, into content-addressed shard files of at most `size` games (SPEC.md §3.7). Pure: same input, same bytes. */
|
|
48
|
+
export declare function splitShards(games: Json[], size: number): Promise<{
|
|
49
|
+
files: Map<string, string>;
|
|
50
|
+
shards: Json[];
|
|
51
|
+
}>;
|
|
52
|
+
export interface SerializeOptions {
|
|
53
|
+
/** Games per shard; at or below this many games the output stays inline. Default 1000. */
|
|
54
|
+
shardSize?: number;
|
|
55
|
+
/** The repo.json this one replaces, as parsed JSON. `updated` is kept when nothing else changed, and the shards it listed that are no longer used are reported as orphans. */
|
|
56
|
+
previous?: Json;
|
|
57
|
+
now?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface Serialized {
|
|
60
|
+
/** The bytes of repo.json. */
|
|
61
|
+
repoJson: string;
|
|
62
|
+
/** Shard files to write, by repo-relative url. */
|
|
63
|
+
shards: Map<string, string>;
|
|
64
|
+
/** Generated shards (`shards/<sha16>.json`) the previous repo.json listed that this one does not: delete them after repo.json is live. */
|
|
65
|
+
orphans: string[];
|
|
66
|
+
}
|
|
67
|
+
/** Serialises a flat repo. Byte-identical for the same input, and `updated` moves only when something else changed. */
|
|
68
|
+
export declare function serialize(repo: Json, { shardSize, previous, now }?: SerializeOptions): Promise<Serialized>;
|
package/dist/builder.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Editing and writing a repo.json (SPEC.md §3, §3.7) in memory. Browser-safe: no filesystem, no Node APIs.
|
|
2
|
+
// Works on the raw JSON, never on a parsed Repo, so hand-edited fields and unknown fields survive.
|
|
3
|
+
import { sha256Hex } from "./hash.js";
|
|
4
|
+
import { LIMITS } from "./parse.js";
|
|
5
|
+
const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
6
|
+
const GENERATED_SHARD = /^shards\/[0-9a-f]{16}\.json$/;
|
|
7
|
+
const encoder = new TextEncoder();
|
|
8
|
+
export const slug = (s) => s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
9
|
+
/** "dir/My_Game-v2.zip" -> "My Game-v2" */
|
|
10
|
+
export const titleOf = (file) => (file.split("/").pop() ?? file).replace(/\.[^.]*$/, "").replace(/_+/g, " ").trim();
|
|
11
|
+
export function emptyRepo(name, now = new Date().toISOString()) {
|
|
12
|
+
return { schema: 1, name, updated: now, games: [] };
|
|
13
|
+
}
|
|
14
|
+
/** Adds a version (newest first) to its game, creating the game if needed; a version already pointing at `file.url` just gets its hash and size refreshed. */
|
|
15
|
+
export function addVersion(repo, v) {
|
|
16
|
+
const games = (repo.games ??= []);
|
|
17
|
+
for (const game of games) {
|
|
18
|
+
for (const old of game.versions ?? []) {
|
|
19
|
+
if (old.file?.url === v.file.url) {
|
|
20
|
+
old.file = { ...old.file, sha256: v.file.sha256, size: v.file.size };
|
|
21
|
+
return game;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const id = v.id ?? slug(v.title ?? "");
|
|
26
|
+
if (id === "")
|
|
27
|
+
throw new Error("cannot derive a game id; pass an id or a title");
|
|
28
|
+
let game = games.find((g) => g.id === id);
|
|
29
|
+
if (!game) {
|
|
30
|
+
if (!v.title)
|
|
31
|
+
throw new Error(`game ${id} does not exist; a title is needed to create it`);
|
|
32
|
+
game = { id, title: v.title, ...(v.work && { work: v.work }), ...(v.meta && { meta: v.meta }), versions: [] };
|
|
33
|
+
games.push(game);
|
|
34
|
+
games.sort(byId);
|
|
35
|
+
}
|
|
36
|
+
const taken = new Set(game.versions.map((x) => x.version));
|
|
37
|
+
let label = v.version;
|
|
38
|
+
if (label === undefined)
|
|
39
|
+
for (let n = game.versions.length + 1; taken.has((label = String(n))); n++)
|
|
40
|
+
;
|
|
41
|
+
else if (taken.has(label))
|
|
42
|
+
throw new Error(`game ${id} already has a version ${label}`);
|
|
43
|
+
const { notes, released, format, file } = v;
|
|
44
|
+
game.versions.unshift({ version: label, file, ...(format && { format }), ...(notes && { notes }), ...(released && { released }) });
|
|
45
|
+
return game;
|
|
46
|
+
}
|
|
47
|
+
/** Adds an archive by its url, deriving the game id and title from the file name. */
|
|
48
|
+
export function upsertArchive(repo, url, hashed, opts = {}) {
|
|
49
|
+
const title = opts.title ?? titleOf(decodeURIComponent(url));
|
|
50
|
+
addVersion(repo, { ...opts, id: opts.id ?? slug(titleOf(decodeURIComponent(url))), title, file: { url, ...hashed } });
|
|
51
|
+
}
|
|
52
|
+
/** Drops one version; a game left with none is dropped too. False when nothing matched. */
|
|
53
|
+
export function removeVersion(repo, gameId, version) {
|
|
54
|
+
const game = (repo.games ?? []).find((g) => g.id === gameId);
|
|
55
|
+
const i = game?.versions.findIndex((v) => v.version === version) ?? -1;
|
|
56
|
+
if (i < 0)
|
|
57
|
+
return false;
|
|
58
|
+
game.versions.splice(i, 1);
|
|
59
|
+
if (game.versions.length === 0)
|
|
60
|
+
removeGame(repo, gameId);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
export function removeGame(repo, gameId) {
|
|
64
|
+
const before = repo.games?.length ?? 0;
|
|
65
|
+
repo.games = (repo.games ?? []).filter((g) => g.id !== gameId);
|
|
66
|
+
return repo.games.length < before;
|
|
67
|
+
}
|
|
68
|
+
export function updateGame(repo, gameId, patch) {
|
|
69
|
+
const game = (repo.games ?? []).find((g) => g.id === gameId);
|
|
70
|
+
if (!game)
|
|
71
|
+
throw new Error(`no game ${gameId}`);
|
|
72
|
+
if (patch.title !== undefined)
|
|
73
|
+
game.title = patch.title;
|
|
74
|
+
for (const key of ["work", "meta"]) {
|
|
75
|
+
const p = patch[key];
|
|
76
|
+
if (p === undefined)
|
|
77
|
+
continue;
|
|
78
|
+
if (p === null) {
|
|
79
|
+
delete game[key];
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
game[key] = { ...game[key], ...p };
|
|
83
|
+
for (const k of Object.keys(game[key]))
|
|
84
|
+
if (game[key][k] === null)
|
|
85
|
+
delete game[key][k];
|
|
86
|
+
if (Object.keys(game[key]).length === 0)
|
|
87
|
+
delete game[key];
|
|
88
|
+
}
|
|
89
|
+
return game;
|
|
90
|
+
}
|
|
91
|
+
/** Moves the games of a sharded repo.json inline, so one flat list can be edited. `readShard` returns a shard file's text by its (repo-relative) url. */
|
|
92
|
+
export async function inlineShards(repo, readShard) {
|
|
93
|
+
for (const shard of repo.shards ?? []) {
|
|
94
|
+
try {
|
|
95
|
+
(repo.games ??= []).push(...JSON.parse(await readShard(shard.url)).games);
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
throw new Error(`shard ${shard.url} is unreadable: ${e.message}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
delete repo.shards;
|
|
102
|
+
}
|
|
103
|
+
/** Splits games, sorted by id, into content-addressed shard files of at most `size` games (SPEC.md §3.7). Pure: same input, same bytes. */
|
|
104
|
+
export async function splitShards(games, size) {
|
|
105
|
+
const sorted = [...games].sort(byId);
|
|
106
|
+
const files = new Map();
|
|
107
|
+
const shards = [];
|
|
108
|
+
for (let i = 0; i < sorted.length; i += size) {
|
|
109
|
+
const chunk = sorted.slice(i, i + size);
|
|
110
|
+
const content = JSON.stringify({ games: chunk }) + "\n";
|
|
111
|
+
const bytes = encoder.encode(content);
|
|
112
|
+
const sha256 = await sha256Hex(bytes);
|
|
113
|
+
const url = `shards/${sha256.slice(0, 16)}.json`;
|
|
114
|
+
files.set(url, content);
|
|
115
|
+
shards.push({ url, sha256, size: bytes.byteLength, count: chunk.length });
|
|
116
|
+
}
|
|
117
|
+
return { files, shards };
|
|
118
|
+
}
|
|
119
|
+
/** Serialises a flat repo. Byte-identical for the same input, and `updated` moves only when something else changed. */
|
|
120
|
+
export async function serialize(repo, { shardSize = 1000, previous, now = new Date().toISOString() } = {}) {
|
|
121
|
+
if (!Number.isInteger(shardSize) || shardSize < 1 || shardSize > LIMITS.gamesPerFile) {
|
|
122
|
+
throw new Error(`shard size must be an integer from 1 to ${LIMITS.gamesPerFile}`);
|
|
123
|
+
}
|
|
124
|
+
if (repo.shards?.length)
|
|
125
|
+
throw new Error("repo has shards; inlineShards first");
|
|
126
|
+
let out = repo;
|
|
127
|
+
let shardFiles = new Map();
|
|
128
|
+
if ((repo.games?.length ?? 0) > shardSize) {
|
|
129
|
+
const { files, shards } = await splitShards(repo.games, shardSize);
|
|
130
|
+
if (shards.length > LIMITS.shards)
|
|
131
|
+
throw new Error(`${shards.length} shards exceeds the cap of ${LIMITS.shards}; raise the shard size`);
|
|
132
|
+
shardFiles = files;
|
|
133
|
+
out = { ...repo, games: [], shards };
|
|
134
|
+
}
|
|
135
|
+
const stable = (r) => JSON.stringify({ ...r, updated: undefined });
|
|
136
|
+
const next = { ...out, updated: previous && stable(previous) === stable(out) ? previous.updated : now };
|
|
137
|
+
const orphans = (previous?.shards ?? []).map((s) => s.url).filter((u) => GENERATED_SHARD.test(u) && !shardFiles.has(u));
|
|
138
|
+
return { repoJson: JSON.stringify(next, null, 2) + "\n", shards: shardFiles, orphans };
|
|
139
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,29 +1,108 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { openAsBlob } from "node:fs";
|
|
2
3
|
import { readFile, stat } from "node:fs/promises";
|
|
3
4
|
import { basename, join, resolve } from "node:path";
|
|
4
5
|
import { parseArgs } from "node:util";
|
|
5
|
-
import {
|
|
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
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
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" }, prune: { type: "boolean" }, "shard-size": { type: "string" } },
|
|
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": {
|
|
@@ -39,10 +118,15 @@ async function main(argv) {
|
|
|
39
118
|
const repo = (await readRepoJson(dir)) ?? emptyRepo(o.name ?? dir.split("/").pop());
|
|
40
119
|
if (repo.shards?.length)
|
|
41
120
|
return console.error("repo.json is sharded; put the archive in the folder and run build"), 1;
|
|
121
|
+
const previous = structuredClone(repo);
|
|
42
122
|
upsertArchive(repo, await relativeUrl(dir, resolve(archive)), await hashFile(resolve(archive)), o);
|
|
43
|
-
await writeRepoJson(dir, repo);
|
|
123
|
+
await writeRepoJson(dir, (await serialize(repo, { previous })).repoJson);
|
|
44
124
|
return console.log("repo.json updated"), 0;
|
|
45
125
|
}
|
|
126
|
+
case "publish":
|
|
127
|
+
return publish(dir, o);
|
|
128
|
+
case "cors":
|
|
129
|
+
return cors(o);
|
|
46
130
|
case "validate":
|
|
47
131
|
return validate(target, o.hosting === true);
|
|
48
132
|
default:
|