@jgengine/assets 0.9.0 → 0.11.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +34 -3
  2. package/README.md +103 -50
  3. package/dist/aliases.d.ts +5 -0
  4. package/dist/aliases.js +28 -9
  5. package/dist/catalogs/build.js +9 -0
  6. package/dist/catalogs/buildSprites.d.ts +10 -0
  7. package/dist/catalogs/buildSprites.js +15 -0
  8. package/dist/catalogs/starter.d.ts +5 -0
  9. package/dist/catalogs/starter.js +10 -1
  10. package/dist/cli/paths.d.ts +9 -1
  11. package/dist/cli/paths.js +11 -1
  12. package/dist/cli/pull.d.ts +12 -0
  13. package/dist/cli/pull.js +210 -15
  14. package/dist/download.d.ts +46 -3
  15. package/dist/download.js +158 -9
  16. package/dist/find.d.ts +20 -1
  17. package/dist/find.js +52 -2
  18. package/dist/generated/index.js +18 -6
  19. package/dist/generated/kaykit-adventurers.json +674 -0
  20. package/dist/generated/kaykit-city-builder.json +863 -0
  21. package/dist/generated/kaykit-dungeon.json +4265 -0
  22. package/dist/generated/kaykit-furniture.json +1115 -0
  23. package/dist/generated/kaykit-skeletons.json +410 -0
  24. package/dist/generated/kaykit-space-base.json +1199 -0
  25. package/dist/generated/quaternius-medieval-village.json +3698 -0
  26. package/dist/generated/quaternius-modular-scifi.json +4013 -0
  27. package/dist/generated/quaternius-stylized-nature.json +1430 -0
  28. package/dist/generated-sprites/index.d.ts +5 -0
  29. package/dist/generated-sprites/index.js +4 -0
  30. package/dist/index.d.ts +9 -2
  31. package/dist/index.js +9 -2
  32. package/dist/indexGen.js +7 -5
  33. package/dist/manifest.d.ts +12 -1
  34. package/dist/materials.d.ts +61 -0
  35. package/dist/materials.js +98 -0
  36. package/dist/packs/starter.d.ts +46 -0
  37. package/dist/packs/starter.js +248 -0
  38. package/dist/provisioning.d.ts +88 -0
  39. package/dist/provisioning.js +74 -0
  40. package/dist/registry-catalog.json +40 -0
  41. package/dist/snippet.d.ts +4 -0
  42. package/dist/snippet.js +27 -0
  43. package/dist/sources/ambientcg.d.ts +5 -0
  44. package/dist/sources/ambientcg.js +64 -0
  45. package/dist/sources/gameicons.d.ts +10 -0
  46. package/dist/sources/gameicons.js +21 -0
  47. package/dist/sources/index.d.ts +9 -2
  48. package/dist/sources/index.js +11 -3
  49. package/dist/sources/kaykit.js +55 -6
  50. package/dist/sources/quaternius.js +60 -9
  51. package/dist/spriteIndexGen.d.ts +17 -0
  52. package/dist/spriteIndexGen.js +108 -0
  53. package/dist/verify.d.ts +1 -0
  54. package/dist/verify.js +38 -1
  55. package/package.json +4 -4
  56. package/skills/jgengine/SKILL.md +51 -0
  57. package/skills/jgengine/api.md +434 -0
  58. package/skills/jgengine/capabilities.md +29 -0
  59. package/skills/jgengine/reference-cartridge.md +72 -0
  60. package/skills/jgengine-assets/SKILL.md +41 -0
  61. package/skills/jgengine-assets/api.md +273 -0
  62. package/skills/jgengine-assets/capabilities.md +13 -0
  63. package/dist/generated/kenney-blaster.json +0 -362
  64. package/dist/generated/kenney-nature.json +0 -3292
  65. package/dist/generated/kenney-space.json +0 -1532
  66. package/dist/sources/kenney.d.ts +0 -2
  67. package/dist/sources/kenney.js +0 -36
  68. package/llms.txt +0 -997
package/dist/cli/pull.js CHANGED
@@ -1,26 +1,34 @@
1
1
  #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
2
3
  import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
4
  import { basename, dirname, join, resolve } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
- import { downloadPackArchive, extractGlbs, extractTextures } from "../download.js";
6
+ import { downloadPackArchive, extractGlbs, extractSpriteFiles, extractTextures } from "../download.js";
6
7
  import { rankAssets } from "../find.js";
7
8
  import { generatedIndex } from "../generated/index.js";
9
+ import { generatedSpriteIndex } from "../generated-sprites/index.js";
8
10
  import { reindex } from "../indexGen.js";
9
11
  import { isScrapeDownload } from "../manifest.js";
12
+ import { extractMaterialMaps } from "../materials.js";
13
+ import { validateAssetReferences } from "../provisioning.js";
10
14
  import { registryCatalog } from "../registry.js";
11
- import { componentWiringSnippet, iconWiringSnippet, modelWiringSnippet } from "../snippet.js";
12
- import { sourceById } from "../sources/index.js";
15
+ import { componentWiringSnippet, iconWiringSnippet, materialWiringSnippet, modelWiringSnippet, spriteWiringSnippet, } from "../snippet.js";
16
+ import { materialSources, sourceById, spriteSources } from "../sources/index.js";
17
+ import { reindexSprites } from "../spriteIndexGen.js";
13
18
  import { verifyManifest } from "../verify.js";
14
- import { resolveGeneratedDir, resolvePackageRoot, resolvePackageTreeRoot } from "./paths.js";
19
+ import { resolveGeneratedDir, resolveGeneratedSpritesDir, resolvePackageRoot, resolvePackageTreeRoot } from "./paths.js";
15
20
  const here = dirname(fileURLToPath(import.meta.url));
16
21
  const packageTreeRoot = resolvePackageTreeRoot(here);
17
22
  const pkgRoot = resolvePackageRoot(here);
18
23
  /** Sibling of `cli/` under `src/` (dev) or `dist/` (published) so reindex writes the tree consumers import. */
19
24
  export const generatedDir = resolveGeneratedDir(here);
25
+ /** Sprite-pack counterpart of `generatedDir`. */
26
+ export const generatedSpritesDir = resolveGeneratedSpritesDir(here);
20
27
  export { resolveGeneratedDir } from "./paths.js";
21
28
  const singlesJson = join(packageTreeRoot, "singles.json");
22
29
  const localDir = join(pkgRoot, "local");
23
30
  const CDN_BASE = "https://cdn.jsdelivr.net/gh/Noisemaker111/jgengine@main/packages/assets/local";
31
+ /** @internal */
24
32
  export function flag(argv, name) {
25
33
  const index = argv.indexOf(`--${name}`);
26
34
  return index >= 0 ? argv[index + 1] : undefined;
@@ -29,6 +37,31 @@ function fail(message) {
29
37
  console.error(`error: ${message}`);
30
38
  process.exit(1);
31
39
  }
40
+ /**
41
+ * The runtime's own fetch, with a curl fallback for hosts it cannot reach —
42
+ * some proxied sandboxes tear down bun's TLS stream to GitHub's release-asset
43
+ * host while curl (which honors SSL_CERT_FILE) gets through fine.
44
+ */
45
+ export const cliFetch = (async (input, init) => {
46
+ try {
47
+ return await fetch(input, init);
48
+ }
49
+ catch (error) {
50
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
51
+ if (init?.method !== undefined && init.method !== "GET")
52
+ throw error;
53
+ try {
54
+ const bytes = execFileSync("curl", ["-fsSL", "--max-time", "600", url], {
55
+ maxBuffer: 1024 * 1024 * 1024,
56
+ });
57
+ return new Response(new Uint8Array(bytes));
58
+ }
59
+ catch {
60
+ throw error;
61
+ }
62
+ }
63
+ });
64
+ /** @internal */
32
65
  export function describeNetworkFailure(error) {
33
66
  const chain = [];
34
67
  let current = error;
@@ -46,6 +79,31 @@ function cmdList(argv) {
46
79
  const category = flag(argv, "category");
47
80
  const source = flag(argv, "source");
48
81
  const limit = Number(flag(argv, "limit") ?? "50");
82
+ if (flag(argv, "kind") === "material") {
83
+ const rows = materialSources.filter((entry) => category === undefined || entry.categories.includes(category));
84
+ for (const entry of rows.slice(0, limit)) {
85
+ console.log(`${entry.id}\t[${entry.categories.join(",")}]\t${entry.license}`);
86
+ }
87
+ console.log(`— ${Math.min(rows.length, limit)} of ${rows.length} materials`);
88
+ return;
89
+ }
90
+ if (flag(argv, "kind") === "sprite") {
91
+ const rows = generatedSpriteIndex.filter((entry) => (category === undefined || entry.categories.includes(category)) &&
92
+ (source === undefined || entry.source === source));
93
+ for (const entry of rows.slice(0, limit)) {
94
+ console.log(`${entry.id}\t[${entry.categories.join(",")}]\t${entry.file}`);
95
+ }
96
+ console.log(`— ${Math.min(rows.length, limit)} of ${rows.length} sprite/icon files`);
97
+ return;
98
+ }
99
+ if (flag(argv, "kind") === "spritePack") {
100
+ const rows = spriteSources.filter((entry) => category === undefined || entry.categories.includes(category));
101
+ for (const entry of rows.slice(0, limit)) {
102
+ console.log(`${entry.id}\t[${entry.categories.join(",")}]\t${entry.license}`);
103
+ }
104
+ console.log(`— ${Math.min(rows.length, limit)} of ${rows.length} sprite/icon packs`);
105
+ return;
106
+ }
49
107
  const rows = generatedIndex.filter((entry) => (category === undefined || entry.categories.includes(category)) &&
50
108
  (source === undefined || entry.source === source));
51
109
  for (const entry of rows.slice(0, limit)) {
@@ -65,9 +123,11 @@ function cmdSearch(argv) {
65
123
  }
66
124
  console.log(`— ${Math.min(rows.length, limit)} of ${rows.length} matches for "${term}"`);
67
125
  }
126
+ /** @internal */
68
127
  export function isPopulated(dir) {
69
128
  return existsSync(dir) && readdirSync(dir).length > 0;
70
129
  }
130
+ /** @internal */
71
131
  export async function cmdPull(argv) {
72
132
  const sourceId = argv[0];
73
133
  if (sourceId === undefined) {
@@ -77,7 +137,7 @@ export async function cmdPull(argv) {
77
137
  if (source === undefined)
78
138
  fail(`unknown source: ${sourceId}`);
79
139
  const outRoot = resolve(flag(argv, "dir") ?? "public");
80
- const outDir = join(outRoot, "models", sourceId);
140
+ const outDir = join(outRoot, packSubdir(source), sourceId);
81
141
  const offline = argv.includes("--offline");
82
142
  const mirrorBase = flag(argv, "mirror") ?? process.env.JGENGINE_ASSETS_MIRROR;
83
143
  if (offline) {
@@ -89,15 +149,53 @@ export async function cmdPull(argv) {
89
149
  return;
90
150
  }
91
151
  const result = await fetchPackInto(source, outRoot, { mirrorBase });
152
+ console.log(describeFetchResult(source, result));
153
+ }
154
+ function packSubdir(source) {
155
+ if (source.kind === "material")
156
+ return "materials";
157
+ if (source.kind === "sprite")
158
+ return "sprites";
159
+ return "models";
160
+ }
161
+ function describeFetchResult(source, result) {
162
+ if (source.kind === "material") {
163
+ return `pulled ${result.textures} material map(s) -> ${result.outDir}`;
164
+ }
165
+ if (source.kind === "sprite") {
166
+ return `pulled ${result.models} sprite/icon file(s) -> ${result.outDir}`;
167
+ }
92
168
  const textureNote = result.textures > 0 ? ` + ${result.textures} texture(s)` : "";
93
- console.log(`pulled ${result.models} models${textureNote} -> ${result.outDir}`);
169
+ return `pulled ${result.models} models${textureNote} -> ${result.outDir}`;
94
170
  }
95
171
  async function fetchPackInto(source, outRoot, options) {
96
- const outDir = join(outRoot, "models", source.id);
172
+ const outDir = join(outRoot, packSubdir(source), source.id);
97
173
  console.log(`resolving ${source.id}${isScrapeDownload(source.download) ? " (scrape)" : ""}` +
98
174
  `${options.mirrorBase !== undefined ? ` [mirror override: ${options.mirrorBase}]` : ""}…`);
99
- const { archive, url, attempted } = await downloadPackArchive(source, { mirrorBase: options.mirrorBase });
175
+ const { archive, url, attempted } = await downloadPackArchive(source, {
176
+ mirrorBase: options.mirrorBase,
177
+ fetchImpl: cliFetch,
178
+ });
100
179
  console.log(`downloaded ${url}${attempted.length > 1 ? ` (after ${attempted.length - 1} failed attempt(s))` : ""}`);
180
+ if (source.kind === "material") {
181
+ const maps = extractMaterialMaps(archive);
182
+ if (maps.every((map) => map.role !== "color")) {
183
+ fail(`no color map found in ${source.id} archive`);
184
+ }
185
+ mkdirSync(outDir, { recursive: true });
186
+ for (const map of maps)
187
+ writeFileSync(join(outDir, map.file), map.bytes);
188
+ return { outDir, models: 0, textures: maps.length, url };
189
+ }
190
+ if (source.kind === "sprite") {
191
+ const files = extractSpriteFiles(archive);
192
+ if (files.length === 0)
193
+ fail(`no .svg/.png files found in ${source.id} archive`);
194
+ mkdirSync(outDir, { recursive: true });
195
+ for (const file of files)
196
+ writeFileSync(join(outDir, file.file), file.bytes);
197
+ return { outDir, models: files.length, textures: 0, url };
198
+ }
101
199
  const glbs = extractGlbs(archive);
102
200
  if (glbs.length === 0)
103
201
  fail(`no .glb files found in ${source.id} archive`);
@@ -157,17 +255,31 @@ function cmdRegisterSingle(argv) {
157
255
  writeSingles(singles);
158
256
  console.log(`added single ${id} -> ${url}${isUrl ? " (0 bytes stored)" : " (copied to local/)"}`);
159
257
  }
160
- const KINDS = ["model", "pack", "component", "icon"];
258
+ const KINDS = [
259
+ "model",
260
+ "pack",
261
+ "material",
262
+ "component",
263
+ "icon",
264
+ "sprite",
265
+ "spritePack",
266
+ ];
161
267
  function describeMatch(match) {
162
268
  switch (match.kind) {
163
269
  case "model":
164
270
  return `[model] ${match.id}${match.via === "alias" ? " (alias)" : match.via === "single" ? " (single)" : ""}`;
165
271
  case "pack":
166
272
  return `[pack] ${match.source} — ${match.title}`;
273
+ case "material":
274
+ return `[material] ${match.id} — ${match.title}`;
167
275
  case "component":
168
276
  return `[component] ${match.name} — ${match.title}`;
169
277
  case "icon":
170
278
  return `[icon] ${match.name}`;
279
+ case "sprite":
280
+ return `[sprite] ${match.id}`;
281
+ case "spritePack":
282
+ return `[spritePack] ${match.source} — ${match.title}`;
171
283
  }
172
284
  }
173
285
  async function performAdd(match, argv) {
@@ -184,19 +296,58 @@ async function performAdd(match, argv) {
184
296
  console.log(iconWiringSnippet(match.name));
185
297
  return;
186
298
  }
299
+ const outRoot = resolve(flag(argv, "dir") ?? "public");
300
+ const mirrorBase = flag(argv, "mirror") ?? process.env.JGENGINE_ASSETS_MIRROR;
301
+ if (match.kind === "material") {
302
+ const source = sourceById.get(match.id);
303
+ if (source === undefined)
304
+ fail(`match resolves to unknown source: ${match.id}`);
305
+ const outDir = join(outRoot, "materials", match.id);
306
+ if (isPopulated(outDir)) {
307
+ console.log(`${match.id} already present in ${outDir}`);
308
+ }
309
+ else {
310
+ const result = await fetchPackInto(source, outRoot, { mirrorBase });
311
+ console.log(describeFetchResult(source, result));
312
+ }
313
+ console.log(`\nwire it in:\n`);
314
+ console.log(materialWiringSnippet(match.id));
315
+ return;
316
+ }
317
+ if (match.kind === "sprite" || match.kind === "spritePack") {
318
+ const source = sourceById.get(match.source);
319
+ if (source === undefined)
320
+ fail(`match resolves to unknown source: ${match.source}`);
321
+ const outDir = join(outRoot, "sprites", match.source);
322
+ if (isPopulated(outDir)) {
323
+ console.log(`${match.source} already present in ${outDir}`);
324
+ }
325
+ else {
326
+ const result = await fetchPackInto(source, outRoot, { mirrorBase });
327
+ console.log(describeFetchResult(source, result));
328
+ }
329
+ const result = reindexSprites(join(outRoot, "sprites"), generatedSpritesDir);
330
+ console.log(`reindexed ${result.total} entries -> ${generatedSpritesDir}`);
331
+ if (match.kind === "sprite") {
332
+ console.log(`\nwire it in:\n`);
333
+ console.log(spriteWiringSnippet(match.id));
334
+ }
335
+ else {
336
+ console.log(`\n${match.title}: browse ids with \`assets list --kind sprite --source ${match.source}\`, then wire like:\n`);
337
+ console.log(spriteWiringSnippet(`${match.source}/<icon>`));
338
+ }
339
+ return;
340
+ }
187
341
  const source = sourceById.get(match.source);
188
342
  if (source === undefined)
189
343
  fail(`match resolves to unknown source: ${match.source}`);
190
- const outRoot = resolve(flag(argv, "dir") ?? "public");
191
344
  const outDir = join(outRoot, "models", match.source);
192
- const mirrorBase = flag(argv, "mirror") ?? process.env.JGENGINE_ASSETS_MIRROR;
193
345
  if (isPopulated(outDir)) {
194
346
  console.log(`${match.source} already present in ${outDir}`);
195
347
  }
196
348
  else {
197
349
  const result = await fetchPackInto(source, outRoot, { mirrorBase });
198
- const textureNote = result.textures > 0 ? ` + ${result.textures} texture(s)` : "";
199
- console.log(`pulled ${result.models} models${textureNote} -> ${result.outDir}`);
350
+ console.log(describeFetchResult(source, result));
200
351
  }
201
352
  const result = reindex(join(outRoot, "models"), generatedDir);
202
353
  console.log(`reindexed ${result.total} entries -> ${generatedDir}`);
@@ -212,7 +363,7 @@ async function performAdd(match, argv) {
212
363
  async function cmdAdd(argv) {
213
364
  const query = argv[0];
214
365
  if (query === undefined) {
215
- fail("usage: add <query> [--kind model|pack|component|icon] [--dir <dir>] [--mirror <baseUrl>] [--json]\n" +
366
+ fail("usage: add <query> [--kind model|pack|material|component|icon|sprite|spritePack] [--dir <dir>] [--mirror <baseUrl>] [--json]\n" +
216
367
  " add <path|url> --license <l> [--category <c>] (register a one-off single into the shipped index)");
217
368
  }
218
369
  // Back-compat: the old `add` registered a one-off single, always with --license.
@@ -255,6 +406,15 @@ function cmdReindex(argv) {
255
406
  console.log(` ${row.source}: ${row.count}`);
256
407
  console.log(`reindexed ${result.total} entries -> ${generatedDir}`);
257
408
  }
409
+ function cmdReindexSprites(argv) {
410
+ const spritesDir = resolve(argv[0] ?? join("public", "sprites"));
411
+ if (!existsSync(spritesDir))
412
+ fail(`sprites dir not found: ${spritesDir}`);
413
+ const result = reindexSprites(spritesDir, generatedSpritesDir);
414
+ for (const row of result.perSource)
415
+ console.log(` ${row.source}: ${row.count}`);
416
+ console.log(`reindexed ${result.total} entries -> ${generatedSpritesDir}`);
417
+ }
258
418
  function cmdVerify() {
259
419
  const result = verifyManifest();
260
420
  if (result.ok) {
@@ -265,6 +425,35 @@ function cmdVerify() {
265
425
  console.error(` ✗ ${error}`);
266
426
  fail(`verify failed with ${result.errors.length} problem(s)`);
267
427
  }
428
+ /**
429
+ * Resolve each id against the provisioning contract and print its owner —
430
+ * `committed`, `provisioned` (with the `assets pull <source>` step), or
431
+ * `dangling`. Exits non-zero if any id is dangling, so a clean-clone gate can
432
+ * feed it logical ids and fail on references no source or single owns.
433
+ */
434
+ function cmdProvenance(argv) {
435
+ const ids = argv.filter((arg) => !arg.startsWith("--"));
436
+ if (ids.length === 0)
437
+ fail("usage: assets provenance <id> [<id>...]");
438
+ const references = ids.map((id) => ({ consumer: "cli", id }));
439
+ const result = validateAssetReferences(references);
440
+ for (const row of result.results) {
441
+ const p = row.provenance;
442
+ const detail = p.kind === "provisioned"
443
+ ? `${p.resolvedPath} (${p.provisioningStep})`
444
+ : p.kind === "committed"
445
+ ? `${p.resolvedPath}`
446
+ : "no owner";
447
+ const mark = row.ok ? "✓" : "✗";
448
+ console.log(` ${mark} ${p.id}: ${p.kind} — ${detail}`);
449
+ }
450
+ if (result.provisioning.length > 0) {
451
+ console.log(`provisioning steps: ${result.provisioning.join(", ")}`);
452
+ }
453
+ if (!result.ok)
454
+ fail(`provenance: ${result.errors.length} dangling reference(s)`);
455
+ console.log("provenance: ok");
456
+ }
268
457
  if (import.meta.main) {
269
458
  const [command, ...rest] = process.argv.slice(2);
270
459
  switch (command) {
@@ -286,11 +475,17 @@ if (import.meta.main) {
286
475
  case "reindex":
287
476
  cmdReindex(rest);
288
477
  break;
478
+ case "reindex-sprites":
479
+ cmdReindexSprites(rest);
480
+ break;
289
481
  case "verify":
290
482
  cmdVerify();
291
483
  break;
484
+ case "provenance":
485
+ cmdProvenance(rest);
486
+ break;
292
487
  default:
293
- console.log("usage: assets <add|list|search|pull|register|reindex|verify> [...args]");
488
+ console.log("usage: assets <add|list|search|pull|register|reindex|reindex-sprites|verify|provenance> [...args]");
294
489
  if (command !== undefined && command !== "help")
295
490
  process.exit(1);
296
491
  }
@@ -1,5 +1,16 @@
1
+ import { type UnzipFileFilter } from "fflate";
1
2
  import { type AssetSource } from "./manifest.js";
2
3
  export type FetchLike = typeof fetch;
4
+ /** Max size of a downloaded (still-compressed) archive, in bytes. Provider zips run tens of MB; this leaves headroom without buffering an unbounded response. */
5
+ export declare const MAX_ARCHIVE_DOWNLOAD_BYTES: number;
6
+ /** Max total uncompressed size this module will inflate out of one archive, in bytes. */
7
+ export declare const MAX_ARCHIVE_UNCOMPRESSED_BYTES: number;
8
+ /** Max number of entries this module will extract out of one archive. */
9
+ export declare const MAX_ARCHIVE_ENTRY_COUNT = 20000;
10
+ /** Max allowed originalSize/size ratio for a single archive entry — past this it's treated as a zip bomb. */
11
+ export declare const MAX_ARCHIVE_COMPRESSION_RATIO = 100;
12
+ /** @internal */
13
+ export declare function boundedExtractFilter(matches: UnzipFileFilter): UnzipFileFilter;
3
14
  export interface ExtractedGlb {
4
15
  file: string;
5
16
  bytes: Uint8Array;
@@ -9,14 +20,18 @@ export interface ExtractedTexture {
9
20
  file: string;
10
21
  bytes: Uint8Array;
11
22
  }
23
+ /** @internal */
12
24
  export declare function findArchiveUrl(html: string, pageUrl: string): string | null;
25
+ /** @internal */
13
26
  export declare function resolveArchiveUrl(source: AssetSource, fetchImpl?: FetchLike): Promise<string>;
27
+ /** @internal */
14
28
  export declare function downloadArchive(url: string, fetchImpl?: FetchLike): Promise<Uint8Array>;
15
29
  /**
16
30
  * Layout for the `--mirror` / `JGENGINE_ASSETS_MIRROR` base URL override: the
17
31
  * archive for a pack is expected at `<baseUrl>/<provider>/<packId>.zip`, e.g.
18
32
  * `https://my-mirror.example.com/kenney/kenney-nature.zip`.
19
- */
33
+ * @internal
34
+ */
20
35
  export declare function mirrorOverrideUrl(baseUrl: string, source: AssetSource): string;
21
36
  /**
22
37
  * Default asset mirror: this repo's own GitHub Releases, reachable from every
@@ -29,7 +44,9 @@ export declare function mirrorOverrideUrl(baseUrl: string, source: AssetSource):
29
44
  * `JGENGINE_ASSETS_NO_DEFAULT_MIRROR=1`.
30
45
  */
31
46
  export declare const DEFAULT_RELEASE_BASE = "https://github.com/Noisemaker111/jgengine/releases/download/packs";
32
- /** URL of `source`'s archive on the default GitHub-release mirror. */
47
+ /** URL of `source`'s archive on the default GitHub-release mirror.
48
+ * @internal
49
+ */
33
50
  export declare function defaultReleaseUrl(source: AssetSource): string;
34
51
  export interface DownloadPackOptions {
35
52
  /** Explicit mirror base override (CLI `--mirror` / `JGENGINE_ASSETS_MIRROR`), tried before the primary provider path. */
@@ -52,8 +69,34 @@ export interface DownloadPackResult {
52
69
  * source supplied the bytes; a mismatch is treated as a failed attempt so the
53
70
  * next source in the chain is tried. Throws with every attempted URL and its
54
71
  * failure reason when all sources fail.
55
- */
72
+ * @internal
73
+ */
56
74
  export declare function downloadPackArchive(source: AssetSource, options?: DownloadPackOptions): Promise<DownloadPackResult>;
75
+ /**
76
+ * Pack a `.gltf` + optional external `.bin` into a single `.glb` so reindex /
77
+ * the shell only ever deal in one-file models. Strips buffer `uri` fields so
78
+ * the binary chunk is self-contained. External image URIs stay as-is (loaders
79
+ * resolve relative to the catalog URL only for GLB-embedded images).
80
+ * @internal
81
+ */
82
+ export declare function packGltfToGlb(gltfBytes: Uint8Array, binBytes?: Uint8Array): Uint8Array;
83
+ /**
84
+ * Pull every GLB out of an archive. Also converts co-located `.gltf` + `.bin`
85
+ * pairs (Quaternius Standard packs on OpenGameArt) into `.glb` so the catalog
86
+ * stays one-file-per-model. GLB wins when both formats share a basename.
87
+ * @internal
88
+ */
57
89
  export declare function extractGlbs(archive: Uint8Array): ExtractedGlb[];
90
+ /** One SVG/PNG file pulled out of a sprite/icon-pack archive by `extractSpriteFiles`. */
91
+ export interface ExtractedSpriteFile {
92
+ file: string;
93
+ bytes: Uint8Array;
94
+ }
95
+ /** Pulls every SVG/PNG out of a sprite/icon-pack archive, deduped by basename regardless of nesting depth.
96
+ * @internal
97
+ */
98
+ export declare function extractSpriteFiles(archive: Uint8Array): ExtractedSpriteFile[];
99
+ /** @internal */
58
100
  export declare function extractTextures(archive: Uint8Array): ExtractedTexture[];
101
+ /** @internal */
59
102
  export declare function sha256Hex(bytes: Uint8Array): Promise<string>;
package/dist/download.js CHANGED
@@ -1,5 +1,34 @@
1
1
  import { unzipSync } from "fflate";
2
2
  import { isScrapeDownload } from "./manifest.js";
3
+ /** Max size of a downloaded (still-compressed) archive, in bytes. Provider zips run tens of MB; this leaves headroom without buffering an unbounded response. */
4
+ export const MAX_ARCHIVE_DOWNLOAD_BYTES = 256 * 1024 * 1024;
5
+ /** Max total uncompressed size this module will inflate out of one archive, in bytes. */
6
+ export const MAX_ARCHIVE_UNCOMPRESSED_BYTES = 512 * 1024 * 1024;
7
+ /** Max number of entries this module will extract out of one archive. */
8
+ export const MAX_ARCHIVE_ENTRY_COUNT = 20_000;
9
+ /** Max allowed originalSize/size ratio for a single archive entry — past this it's treated as a zip bomb. */
10
+ export const MAX_ARCHIVE_COMPRESSION_RATIO = 100;
11
+ /** @internal */
12
+ export function boundedExtractFilter(matches) {
13
+ let totalUncompressedBytes = 0;
14
+ let entryCount = 0;
15
+ return (file) => {
16
+ if (!matches(file))
17
+ return false;
18
+ if (file.size > 0 && file.originalSize / file.size > MAX_ARCHIVE_COMPRESSION_RATIO) {
19
+ throw new Error(`archive entry "${file.name}" has a ${(file.originalSize / file.size).toFixed(0)}x compression ratio, exceeds the ${MAX_ARCHIVE_COMPRESSION_RATIO}x zip-bomb guard`);
20
+ }
21
+ totalUncompressedBytes += file.originalSize;
22
+ if (totalUncompressedBytes > MAX_ARCHIVE_UNCOMPRESSED_BYTES) {
23
+ throw new Error(`archive would extract more than the ${MAX_ARCHIVE_UNCOMPRESSED_BYTES} byte uncompressed-size cap`);
24
+ }
25
+ entryCount += 1;
26
+ if (entryCount > MAX_ARCHIVE_ENTRY_COUNT) {
27
+ throw new Error(`archive has more than the ${MAX_ARCHIVE_ENTRY_COUNT} entry cap`);
28
+ }
29
+ return true;
30
+ };
31
+ }
3
32
  function resolveRelative(path, pageUrl) {
4
33
  try {
5
34
  return new URL(path, pageUrl).toString();
@@ -8,6 +37,7 @@ function resolveRelative(path, pageUrl) {
8
37
  return path;
9
38
  }
10
39
  }
40
+ /** @internal */
11
41
  export function findArchiveUrl(html, pageUrl) {
12
42
  const matches = html.match(/[^\s"'()<>]+\.zip/gi);
13
43
  if (matches === null)
@@ -26,6 +56,7 @@ function score(candidate) {
26
56
  value += 1;
27
57
  return value;
28
58
  }
59
+ /** @internal */
29
60
  export async function resolveArchiveUrl(source, fetchImpl = fetch) {
30
61
  const download = source.download;
31
62
  if (!isScrapeDownload(download))
@@ -40,17 +71,30 @@ export async function resolveArchiveUrl(source, fetchImpl = fetch) {
40
71
  }
41
72
  return url;
42
73
  }
74
+ /** @internal */
43
75
  export async function downloadArchive(url, fetchImpl = fetch) {
44
76
  const response = await fetchImpl(url, { redirect: "follow" });
45
77
  if (!response.ok)
46
78
  throw new Error(`download ${url} -> HTTP ${response.status}`);
47
- return new Uint8Array(await response.arrayBuffer());
79
+ const declaredLength = response.headers.get("content-length");
80
+ if (declaredLength !== null) {
81
+ const declared = Number(declaredLength);
82
+ if (Number.isFinite(declared) && declared > MAX_ARCHIVE_DOWNLOAD_BYTES) {
83
+ throw new Error(`download ${url} declares ${declared} bytes, exceeds the ${MAX_ARCHIVE_DOWNLOAD_BYTES} byte cap`);
84
+ }
85
+ }
86
+ const bytes = new Uint8Array(await response.arrayBuffer());
87
+ if (bytes.byteLength > MAX_ARCHIVE_DOWNLOAD_BYTES) {
88
+ throw new Error(`download ${url} is ${bytes.byteLength} bytes, exceeds the ${MAX_ARCHIVE_DOWNLOAD_BYTES} byte cap`);
89
+ }
90
+ return bytes;
48
91
  }
49
92
  /**
50
93
  * Layout for the `--mirror` / `JGENGINE_ASSETS_MIRROR` base URL override: the
51
94
  * archive for a pack is expected at `<baseUrl>/<provider>/<packId>.zip`, e.g.
52
95
  * `https://my-mirror.example.com/kenney/kenney-nature.zip`.
53
- */
96
+ * @internal
97
+ */
54
98
  export function mirrorOverrideUrl(baseUrl, source) {
55
99
  const base = baseUrl.replace(/\/+$/, "");
56
100
  return `${base}/${source.provider}/${source.id}.zip`;
@@ -66,7 +110,9 @@ export function mirrorOverrideUrl(baseUrl, source) {
66
110
  * `JGENGINE_ASSETS_NO_DEFAULT_MIRROR=1`.
67
111
  */
68
112
  export const DEFAULT_RELEASE_BASE = "https://github.com/Noisemaker111/jgengine/releases/download/packs";
69
- /** URL of `source`'s archive on the default GitHub-release mirror. */
113
+ /** URL of `source`'s archive on the default GitHub-release mirror.
114
+ * @internal
115
+ */
70
116
  export function defaultReleaseUrl(source) {
71
117
  return `${DEFAULT_RELEASE_BASE}/${source.provider}-${source.id}.zip`;
72
118
  }
@@ -89,7 +135,8 @@ async function verifyPinnedSha(source, archive) {
89
135
  * source supplied the bytes; a mismatch is treated as a failed attempt so the
90
136
  * next source in the chain is tried. Throws with every attempted URL and its
91
137
  * failure reason when all sources fail.
92
- */
138
+ * @internal
139
+ */
93
140
  export async function downloadPackArchive(source, options = {}) {
94
141
  const fetchImpl = options.fetchImpl ?? fetch;
95
142
  const attempted = [];
@@ -135,10 +182,8 @@ export async function downloadPackArchive(source, options = {}) {
135
182
  throw new Error(`failed to download ${source.id} from all ${attempted.length} source(s):\n` +
136
183
  errors.map((line) => ` - ${line}`).join("\n"));
137
184
  }
138
- export function extractGlbs(archive) {
139
- const entries = unzipSync(archive, {
140
- filter: (file) => /\.glb$/i.test(file.name),
141
- });
185
+ function dedupeByBasename(archive, pattern) {
186
+ const entries = unzipSync(archive, { filter: boundedExtractFilter((file) => pattern.test(file.name)) });
142
187
  const byName = new Map();
143
188
  for (const [path, bytes] of Object.entries(entries)) {
144
189
  const base = path.split("/").pop();
@@ -149,10 +194,113 @@ export function extractGlbs(archive) {
149
194
  }
150
195
  return Array.from(byName, ([file, bytes]) => ({ file, bytes })).sort((a, b) => a.file.localeCompare(b.file));
151
196
  }
197
+ /**
198
+ * Pack a `.gltf` + optional external `.bin` into a single `.glb` so reindex /
199
+ * the shell only ever deal in one-file models. Strips buffer `uri` fields so
200
+ * the binary chunk is self-contained. External image URIs stay as-is (loaders
201
+ * resolve relative to the catalog URL only for GLB-embedded images).
202
+ * @internal
203
+ */
204
+ export function packGltfToGlb(gltfBytes, binBytes) {
205
+ const json = JSON.parse(new TextDecoder().decode(gltfBytes));
206
+ if (Array.isArray(json.buffers)) {
207
+ for (const buffer of json.buffers) {
208
+ delete buffer.uri;
209
+ if (binBytes !== undefined)
210
+ buffer.byteLength = binBytes.byteLength;
211
+ }
212
+ }
213
+ const jsonText = JSON.stringify(json);
214
+ const jsonPad = (4 - (jsonText.length % 4)) % 4;
215
+ const jsonChunk = new Uint8Array(jsonText.length + jsonPad);
216
+ new TextEncoder().encodeInto(jsonText, jsonChunk);
217
+ for (let i = jsonText.length; i < jsonChunk.length; i++)
218
+ jsonChunk[i] = 0x20;
219
+ const binPad = binBytes === undefined ? 0 : (4 - (binBytes.byteLength % 4)) % 4;
220
+ const binChunkLen = binBytes === undefined ? 0 : binBytes.byteLength + binPad;
221
+ const total = 12 +
222
+ 8 +
223
+ jsonChunk.byteLength +
224
+ (binBytes === undefined ? 0 : 8 + binChunkLen);
225
+ const out = new Uint8Array(total);
226
+ const view = new DataView(out.buffer);
227
+ view.setUint32(0, 0x46546c67, true); // glTF
228
+ view.setUint32(4, 2, true);
229
+ view.setUint32(8, total, true);
230
+ let o = 12;
231
+ view.setUint32(o, jsonChunk.byteLength, true);
232
+ view.setUint32(o + 4, 0x4e4f534a, true); // JSON
233
+ out.set(jsonChunk, o + 8);
234
+ o += 8 + jsonChunk.byteLength;
235
+ if (binBytes !== undefined) {
236
+ view.setUint32(o, binChunkLen, true);
237
+ view.setUint32(o + 4, 0x004e4942, true); // BIN
238
+ out.set(binBytes, o + 8);
239
+ }
240
+ return out;
241
+ }
242
+ /**
243
+ * Pull every GLB out of an archive. Also converts co-located `.gltf` + `.bin`
244
+ * pairs (Quaternius Standard packs on OpenGameArt) into `.glb` so the catalog
245
+ * stays one-file-per-model. GLB wins when both formats share a basename.
246
+ * @internal
247
+ */
248
+ export function extractGlbs(archive) {
249
+ const entries = unzipSync(archive, {
250
+ filter: boundedExtractFilter((file) => /\.(glb|gltf|bin)$/i.test(file.name)),
251
+ });
252
+ const byDirBase = new Map();
253
+ for (const [path, bytes] of Object.entries(entries)) {
254
+ const parts = path.replace(/\\/g, "/").split("/");
255
+ const file = parts.pop();
256
+ if (file === undefined || file.length === 0)
257
+ continue;
258
+ const dir = parts.join("/");
259
+ // KayKit ships some files as `name.gltf.glb` — strip both suffixes for a clean id.
260
+ const base = file.replace(/\.gltf\.glb$/i, "").replace(/\.(glb|gltf|bin)$/i, "");
261
+ if (base.length === 0 || base === file)
262
+ continue;
263
+ const extMatch = file.match(/\.(glb|gltf|bin)$/i);
264
+ if (extMatch === null)
265
+ continue;
266
+ const ext = extMatch[1].toLowerCase();
267
+ const key = `${dir}\0${base}`;
268
+ const slot = byDirBase.get(key) ?? {};
269
+ if (ext === "glb")
270
+ slot.glb = bytes;
271
+ else if (ext === "gltf")
272
+ slot.gltf = bytes;
273
+ else if (ext === "bin")
274
+ slot.bin = bytes;
275
+ byDirBase.set(key, slot);
276
+ }
277
+ const byName = new Map();
278
+ for (const [key, slot] of byDirBase) {
279
+ const base = key.split("\0")[1];
280
+ const file = `${base}.glb`;
281
+ if (slot.glb !== undefined) {
282
+ if (!byName.has(file))
283
+ byName.set(file, slot.glb);
284
+ continue;
285
+ }
286
+ if (slot.gltf !== undefined) {
287
+ if (!byName.has(file))
288
+ byName.set(file, packGltfToGlb(slot.gltf, slot.bin));
289
+ }
290
+ }
291
+ return Array.from(byName, ([file, bytes]) => ({ file, bytes })).sort((a, b) => a.file.localeCompare(b.file));
292
+ }
293
+ /** Pulls every SVG/PNG out of a sprite/icon-pack archive, deduped by basename regardless of nesting depth.
294
+ * @internal
295
+ */
296
+ export function extractSpriteFiles(archive) {
297
+ return dedupeByBasename(archive, /\.(svg|png)$/i);
298
+ }
152
299
  const TEXTURE_ENTRY = /(?:^|\/)(Textures\/[^/]+)$/i;
300
+ /** @internal */
153
301
  export function extractTextures(archive) {
154
302
  const entries = unzipSync(archive, {
155
- filter: (file) => TEXTURE_ENTRY.test(file.name),
303
+ filter: boundedExtractFilter((file) => TEXTURE_ENTRY.test(file.name)),
156
304
  });
157
305
  const byRel = new Map();
158
306
  for (const [path, bytes] of Object.entries(entries)) {
@@ -165,6 +313,7 @@ export function extractTextures(archive) {
165
313
  }
166
314
  return Array.from(byRel, ([file, bytes]) => ({ file, bytes })).sort((a, b) => a.file.localeCompare(b.file));
167
315
  }
316
+ /** @internal */
168
317
  export async function sha256Hex(bytes) {
169
318
  const buffer = new ArrayBuffer(bytes.byteLength);
170
319
  new Uint8Array(buffer).set(bytes);