@ringozz/godot 4.7.2-664 → 4.7.2-676

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,7 +54,7 @@ await import('./app.ts'); // may now import @ringozz/godot / react-godot freely
54
54
  | `@ringozz/godot/boot` | Platform boot: `getNativeModule()`; web `preloadGodot()` (awaited before importing the app; no-op on desktop) |
55
55
  | `@ringozz/godot/load` | Async resource loading: `loadResourceAsync`, `loadAsset`, `loadAssetPack` (native `.pck` packs), `configurePacks`/`loadPackEntry` (content packs) |
56
56
  | `@ringozz/godot/bake` | `buildAssetPacks(opts, run?)` + the `godot pack` subcommand: bake the project's boot `.pck` plus one per declared content pack, with their loose-texture documents. `opts.cwd` picks the project root and `run` replaces the editor, so a test can bake a temp project |
57
- | `@ringozz/godot/pack` | The pack protocol the bake writes and the plugin/runtime read: `PACK_DOC`/`RELEASE`/`FILES_DIR` and the boot's names, `parseDoc`/`serializeDoc`/`parseRelease`, `packEntries`/`godotEntry`/`entryId`/`packPath`/`pckOf`, and `resolveEntry` (what to mount and stage for one entry) |
57
+ | `@ringozz/godot/pack` | The pack protocol the bake writes and the plugin/runtime read: `PACK_DOC`/`RELEASE`/`FILES_DIR` and the boot's names, `parseDoc`/`serializeDoc`/`parseRelease`, `packEntries`/`godotEntry`/`entryId`/`pckOf`, and `resolveEntry` (what to mount and stage for one entry) |
58
58
  | `@ringozz/godot/debug` | `initDebug()`, `dumpStr`, `snapshot`, `inspect`, `click`, `find`, `gc` |
59
59
 
60
60
  Class-scoped enums use bare names (import `ProcessMode` from `@ringozz/godot/Node`, not `NodeProcessMode`).
@@ -111,7 +111,11 @@ A content pack is deployed apart from the app, so nothing about it is in the bun
111
111
 
112
112
  ```ts
113
113
  import { configurePacks } from '@ringozz/godot/load';
114
- configurePacks({ at: (path) => `https://cdn.example/packs/${path}` }); // '<name>/<version>/<file>'
114
+ // The host owns the base: a pack's name, its version and one of its files, addressed its own way.
115
+ configurePacks({
116
+ at: (name, version, file) => `https://cdn.example/packs/${name}@${version}/${file}`,
117
+ release: 'https://cdn.example/packs/release.json', // or the map itself: `{ '@tiv/beach': { version, deps } }`
118
+ });
115
119
  ```
116
120
 
117
121
  The boot pack — the app's own sources, which ship beside it — needs no config: the bake renders it into a module (`.godot/packs/boot-pack.js`: its `.pck`, plus a bundled URL per loose product it owns), which every imported asset pulls in, and `configurePacks` merges, so the app's `at` and the boot's config may land in either order. A pack declares its entries (`exports` → the `godot` condition, the `res://` path) and ships **no entry module**: the preload plugin serves one, built from the bake's documents, with one named export per entry. So importing a pack is an ordinary import — and the bake emits the types it resolves to (`package.d.ts`, named by the pack's `types`):
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ringozz/godot",
3
3
  "author": "Vladimir Davidovich",
4
- "version": "4.7.2-664",
4
+ "version": "4.7.2-676",
5
5
  "description": "Node-API bindings for Godot Engine — call Godot classes from JavaScript",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -67,9 +67,9 @@
67
67
  "precision": "single"
68
68
  },
69
69
  "optionalDependencies": {
70
- "@ringozz/godot-macos-arm64": "^4.7.2-664",
71
- "@ringozz/godot-windows-x86_64": "^4.7.2-664",
72
- "@ringozz/godot-web-wasm32": "^4.7.2-664"
70
+ "@ringozz/godot-macos-arm64": "^4.7.2-676",
71
+ "@ringozz/godot-windows-x86_64": "^4.7.2-676",
72
+ "@ringozz/godot-web-wasm32": "^4.7.2-676"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "@types/bun": "*"
package/src/bake.ts CHANGED
@@ -3,8 +3,9 @@
3
3
  ***********************************************************************/
4
4
 
5
5
  import { spawnSync } from 'node:child_process';
6
+ import { createHash, type Hash } from 'node:crypto';
6
7
  import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
7
- import { dirname, join, resolve } from 'node:path';
8
+ import { dirname, extname, join, resolve } from 'node:path';
8
9
  import { fileURLToPath } from 'node:url';
9
10
  import { resolveEditor } from './editor.ts';
10
11
  import { packedExtensions, scannedExtensions } from './formats.ts';
@@ -50,6 +51,27 @@ const HERE = dirname(fileURLToPath(import.meta.url));
50
51
  /** The dump's one build-time output, rewritten by every bake. */
51
52
  const DUMP = '.godot/pack-dump.json';
52
53
 
54
+ /** The digest of every bake input, beside the packs: written by a bake that finished, read by the next. */
55
+ const STAMP = '.stamp.json';
56
+
57
+ /**
58
+ * Removes everything a bake derives — the import cache, the documents, the packs — and never a source.
59
+ * `godot clean` is the answer to the two states a bake cannot repair by itself: orphaned products left
60
+ * by sources that moved or were renamed, and a derived file whose import was never forced.
61
+ */
62
+ export function cleanProject(cwd = process.cwd()): void {
63
+ rmSync(resolve(cwd, '.godot'), { recursive: true, force: true });
64
+ console.log('[pack] cleaned .godot');
65
+ }
66
+
67
+ /**
68
+ * Source the engine never imports or packs — the app's own code, sitting in the same tree as its
69
+ * assets. Editing it cannot change a pack, and it is the most common edit there is, so the digest
70
+ * skips it. The list is a deny-list on purpose: an unknown extension re-bakes (harmless) rather than
71
+ * going stale (a pack that silently lags its sources).
72
+ */
73
+ const SOURCE = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.md', '.yaml', '.yml', '.css']);
74
+
53
75
  export interface BuildAssetPackOptions {
54
76
  /** The project root the bake reads and writes (default: `process.cwd()`). */
55
77
  cwd?: string;
@@ -63,6 +85,8 @@ export interface BuildAssetPackOptions {
63
85
  preset?: string;
64
86
  /** Editor executable override (default: `resolveEditor()`). */
65
87
  godot?: string;
88
+ /** Bake even when nothing the bake reads has changed since the last one (default: false). */
89
+ force?: boolean;
66
90
  }
67
91
 
68
92
  /** A content pack: a directory that declares itself with a `package.json`. */
@@ -96,19 +120,37 @@ export async function buildAssetPacks(opts: BuildAssetPackOptions = {}, run?: Ru
96
120
  const bootRoots = opts.roots ?? ['res://src'];
97
121
  const out = opts.out ?? PACK_DIR;
98
122
  const preset = opts.preset ?? 'Pack';
99
- const exec = run ?? runner(opts.godot ?? (await resolveEditor()), cwd);
123
+ const editor = opts.godot ?? (run ? null : await resolveEditor());
124
+ const exec = run ?? runner(editor!, cwd);
100
125
  mkdirSync(resolve(cwd, out), { recursive: true });
101
126
 
127
+ const dlc = rel(opts.dlc ?? 'res://dlc');
128
+ const packs = discoverPacks(cwd, dlc);
129
+
130
+ const presets = resolve(cwd, 'export_presets.cfg');
131
+ const cfg = readFileSync(presets, 'utf8');
132
+
133
+ // Nothing the bake reads has changed since the last one that finished: an import scan, a dump and
134
+ // one export per pack are the whole cost of a dev server start, and none of it is worth repeating
135
+ // for a project nobody touched. The stamp is the digest of every input, not a timestamp of the
136
+ // outputs — a pack's `.pck` is rewritten on every export, so it can't answer this.
137
+ const stamp = resolve(cwd, out, STAMP);
138
+ const inputs = inputsOf({ cwd, dirs: [...bootRoots.map(rel), ...packs.map((pack) => pack.dir)], cfg, editor });
139
+ if (!opts.force && readStamp(stamp) === inputs) {
140
+ console.log(`[pack] up to date (${out})`);
141
+ return;
142
+ }
143
+
102
144
  // The editor's import scan must not walk the installed dependency tree: a workspace-linked pack
103
145
  // would be imported a second time through the link, at a depth its relative paths (a terrain
104
146
  // `.webparray`'s layer refs) do not survive. `.gdignore` is the editor's own skip marker; the
105
147
  // bundler never looks at it, and the sources are imported once at their real `res://` paths.
106
- const installed = resolve(cwd, 'node_modules');
107
- if (existsSync(installed)) writeFileSync(join(installed, '.gdignore'), '');
148
+ // A pack that declares another pack gets that link *inside itself* (`node_modules` beside its
149
+ // `package.json`), so each pack's tree needs the marker as well as the root one.
150
+ const installed = [resolve(cwd, 'node_modules'), ...packs.map((pack) => resolve(cwd, pack.dir, 'node_modules'))];
151
+ for (const dir of installed.filter((dir) => existsSync(dir))) writeFileSync(join(dir, '.gdignore'), '');
108
152
  exec(['--headless', '--path', '.', '--import']);
109
153
 
110
- const dlc = rel(opts.dlc ?? 'res://dlc');
111
- const packs = discoverPacks(cwd, dlc);
112
154
  assertScopes(bootRoots.map(rel), packs);
113
155
  const dirOf = new Map(packs.map((pack) => [pack.name, pack.dir]));
114
156
  const release = releaseOf(packs);
@@ -117,8 +159,6 @@ export async function buildAssetPacks(opts: BuildAssetPackOptions = {}, run?: Ru
117
159
  const bootDirs = bootRoots.map(rel);
118
160
  const all = [...bootDirs, ...packs.map((pack) => pack.dir)];
119
161
 
120
- const presets = resolve(cwd, 'export_presets.cfg');
121
- const cfg = readFileSync(presets, 'utf8');
122
162
  // A plan's routes are relative to the converter's output root, which is where the packs live.
123
163
  const ctx: Bake = { run: exec, cwd, cfg, preset, base: excludeFilter(cfg), all, planBase: resPath(dlc) };
124
164
  try {
@@ -126,18 +166,22 @@ export async function buildAssetPacks(opts: BuildAssetPackOptions = {}, run?: Ru
126
166
  // that *owns* it, whoever needs it). One dump is also one editor run instead of one per pack.
127
167
  const dump = runDump(ctx, [...bootRoots, ...packs.map((pack) => resPath(pack.dir))]);
128
168
 
129
- const boot = manifestOf(dump, cwd, out, bootDirs);
130
- assertRefs('the boot pack', boot.external, bootRoots);
131
- exportPack(ctx, bootDirs, `${out}/${BOOT_PCK}`);
132
- writeFileSync(resolve(cwd, out, BOOT_MANIFEST), serializeDoc({ needs: boot.needs, stage: boot.stage }));
133
- writeFileSync(resolve(cwd, out, BOOT_MODULE), bootModule(boot));
134
- console.log(`[pack] ${BOOT_PCK} over ${Object.keys(boot.needs).length} sources`);
169
+ if (bootDirs.length) {
170
+ const boot = manifestOf(dump, cwd, out, bootDirs);
171
+ assertRefs('the boot pack', boot.external, bootRoots);
172
+ exportPack(ctx, bootDirs, `${out}/${BOOT_PCK}`);
173
+ writeFileSync(resolve(cwd, out, BOOT_MANIFEST), serializeDoc({ needs: boot.needs, stage: boot.stage }));
174
+ writeFileSync(resolve(cwd, out, BOOT_MODULE), bootModule(boot));
175
+ console.log(`[pack] ${BOOT_PCK} over ${Object.keys(boot.needs).length} sources`);
176
+ }
135
177
 
136
178
  for (const pack of packs) {
137
179
  const version = release[pack.name].version;
138
- // Version-addressed, exactly as a client fetches it — a local bake then exercises the same
139
- // URLs a CDN serves (`packPath`), and nothing is ever rewritten in place.
140
- const dir = `${out}/${pack.name}/${version}`;
180
+ // One directory per pack, holding exactly what its npm package holds: the `.pck`, its
181
+ // document, the loose products, the types and a manifest. So a bake's output and an installed
182
+ // pack have the same layout, and a host serves either with one static root. The version is
183
+ // the release's (`release.json`), not a directory's — a bake builds one per pack.
184
+ const dir = `${out}/${pack.name}`;
141
185
  mkdirSync(resolve(cwd, dir), { recursive: true });
142
186
  const own = manifestOf(dump, cwd, dir, [pack.dir]);
143
187
  const entries = packEntries(pack.exports);
@@ -158,20 +202,43 @@ export async function buildAssetPacks(opts: BuildAssetPackOptions = {}, run?: Ru
158
202
  stage: own.stage,
159
203
  }),
160
204
  );
161
- // The pack's types, emitted beside its own `package.json` (whose `types` names them): a
162
- // build-time plugin can serve the module, not its types, so the one declaration it already
163
- // carries becomes a declaration file too.
164
- const types = resolve(cwd, pack.dir, 'package.d.ts');
165
- if (entries.length) {
166
- writeFileSync(types, packTypes(pack.exports));
205
+ // The pack's types, beside its own `package.json` (whose `types` names them) and in its
206
+ // output directory: a client installs the pack by name, so the types have to travel with it.
207
+ const types = entries.length ? packTypes(pack.exports) : null;
208
+ if (types) {
209
+ writeFileSync(resolve(cwd, pack.dir, 'package.d.ts'), types);
210
+ writeFileSync(resolve(cwd, dir, 'package.d.ts'), types);
167
211
  } else {
168
- rmSync(types, { force: true });
212
+ rmSync(resolve(cwd, pack.dir, 'package.d.ts'), { force: true });
169
213
  }
214
+ // The output directory *is* the deployable artifact: `npm publish` of it ships the `.pck`,
215
+ // its document, the loose products and the types, and its declared dependencies — so a
216
+ // level pulls its shared pack in on install, and nothing is assembled later.
217
+ writeFileSync(
218
+ resolve(cwd, dir, 'package.json'),
219
+ `${JSON.stringify(
220
+ {
221
+ name: pack.name,
222
+ version,
223
+ dependencies: Object.keys(pack.dependencies).length ? pack.dependencies : undefined,
224
+ types: types ? './package.d.ts' : undefined,
225
+ files: [PACK_DOC, pckOf(pack.name), `${FILES_DIR}/**`, ...(types ? ['package.d.ts'] : [])],
226
+ },
227
+ null,
228
+ '\t',
229
+ )}\n`,
230
+ );
170
231
  console.log(`[pack] ${pack.name}@${version} over ${Object.keys(own.needs).length} sources`);
171
232
  }
172
233
  if (packs.length) {
173
234
  writeFileSync(resolve(cwd, out, RELEASE), serializeDoc(release));
235
+ } else {
236
+ // Nothing to release: a release document left by a previous bake would name packs this
237
+ // project no longer builds.
238
+ rmSync(resolve(cwd, out, RELEASE), { force: true });
174
239
  }
240
+ // Only a bake that got this far can say the project is up to date next time.
241
+ writeFileSync(stamp, `${inputs}\n`);
175
242
  } finally {
176
243
  // The preset is scoped per pack, so it is rewritten per export and put back once.
177
244
  writeFileSync(presets, cfg);
@@ -453,6 +520,56 @@ function withExcludes(cfg: string, preset: string, exclude: string[]): string {
453
520
  return `${cfg.slice(0, start)}exclude_filter=${JSON.stringify(exclude.join(','))}${cfg.slice(start + line[0].length)}`;
454
521
  }
455
522
 
523
+ /**
524
+ * Everything a bake reads, as one digest: each resource tree's files (`path/size/mtime` — content is the
525
+ * editor's to read, and a checkout that only moves timestamps re-bakes, which is the safe direction),
526
+ * the preset, the dump script, the tool, and the editor it runs.
527
+ */
528
+ function inputsOf({ cwd, dirs, cfg, editor }: {
529
+ cwd: string;
530
+ dirs: string[];
531
+ cfg: string;
532
+ editor: string | null;
533
+ }): string {
534
+ const hash = createHash('sha1')
535
+ .update(cfg)
536
+ .update(readFileSync(join(HERE, 'dump-deps.gd')))
537
+ .update(readFileSync(join(HERE, '..', 'package.json')));
538
+ // The executable itself, not its `--version`: asking would spawn it, which is the cost the stamp
539
+ // exists to avoid.
540
+ hash.update(editor ? `${statSync(editor).size}:${statSync(editor).mtimeMs}` : 'injected');
541
+ for (const dir of dirs) digestTree(resolve(cwd, dir), '', hash);
542
+ return hash.digest('hex');
543
+ }
544
+
545
+ /** Feeds a tree's files into `hash`: the bake's own outputs and the app's own sources are not inputs. */
546
+ function digestTree(abs: string, rel: string, hash: Hash): void {
547
+ let names: string[];
548
+ try {
549
+ names = readdirSync(abs);
550
+ } catch {
551
+ return;
552
+ }
553
+ for (const name of names.sort()) {
554
+ // The bake's own output beside a pack's manifest, the app's own sources, and dependencies are
555
+ // not inputs: editing them cannot change a pack.
556
+ if (name === 'node_modules' || name.startsWith('.') || name === 'package.d.ts' || SOURCE.has(extname(name))) continue;
557
+ const child = join(abs, name);
558
+ const stat = statSync(child);
559
+ if (stat.isDirectory()) digestTree(child, `${rel}/${name}`, hash);
560
+ else hash.update(`${rel}/${name}:${stat.size}:${stat.mtimeMs}\n`);
561
+ }
562
+ }
563
+
564
+ /** The stamp a bake left, or `null` — a stamp this can't read is a project that gets baked. */
565
+ function readStamp(file: string): string | null {
566
+ try {
567
+ return readFileSync(file, 'utf8').trim();
568
+ } catch {
569
+ return null;
570
+ }
571
+ }
572
+
456
573
  /** Parses `godot pack` CLI args into {@link BuildAssetPackOptions}. */
457
574
  export function parsePackArgs(args: string[]): BuildAssetPackOptions {
458
575
  const opt = (flag: string): string | undefined => {
@@ -460,10 +577,11 @@ export function parsePackArgs(args: string[]): BuildAssetPackOptions {
460
577
  return i >= 0 ? args[i + 1] : undefined;
461
578
  };
462
579
  return {
463
- roots: opt('--roots')?.split(',').filter(Boolean),
580
+ roots: args.includes('--no-boot') ? [] : opt('--roots')?.split(',').filter(Boolean),
464
581
  dlc: opt('--dlc'),
465
582
  out: opt('--out'),
466
583
  preset: opt('--preset'),
467
584
  godot: opt('--godot'),
585
+ force: args.includes('--force'),
468
586
  };
469
587
  }
package/src/editor.ts CHANGED
@@ -111,6 +111,11 @@ if (import.meta.main) {
111
111
  await buildAssetPacks(parsePackArgs(process.argv.slice(3)));
112
112
  process.exit(0);
113
113
  }
114
+ if (command === 'clean') {
115
+ const { cleanProject } = await import('./bake.ts');
116
+ cleanProject();
117
+ process.exit(0);
118
+ }
114
119
  const exe = await resolveEditor();
115
120
  const result = spawnSync(exe, process.argv.slice(2), { stdio: 'inherit', env: { ...process.env, GODOT_NODE_WRAPPER: '1' } });
116
121
  process.exit(result.status ?? 1);
@@ -7,9 +7,9 @@
7
7
  ; (see `dump-deps.gd`), not read from the pack. `export_filter="all_resources"`
8
8
  ; ignores `include_filter` — scope with `exclude_filter` only.
9
9
  ;
10
- ; The bake always splits — one pack per scene (`Pack: <scene>`) plus a shared one — and
11
- ; derives those presets from this one, pruning them again after exporting. Don't write
12
- ; them by hand.
10
+ ; The bake rewrites this preset's `exclude_filter` once per pack (the boot pack, then one per
11
+ ; content pack) and puts the file back when it finishes, so the export carries exactly one scope.
12
+ ; Don't write those scopes by hand.
13
13
 
14
14
  [preset.0]
15
15
 
package/src/load.ts CHANGED
@@ -13,9 +13,7 @@ import { transcoder } from '@ringozz/godot/transcoder';
13
13
  import {
14
14
  FILES_DIR,
15
15
  PACK_DOC,
16
- RELEASE,
17
16
  mountClosure,
18
- packPath,
19
17
  parseDoc,
20
18
  parseRelease,
21
19
  pckOf,
@@ -368,12 +366,23 @@ export interface MountedPack {
368
366
  url: (file: string) => string;
369
367
  }
370
368
 
371
- /** How the runtime reads content packs: `at('<name>/<version>/<file>')` → a fetchable URL. */
369
+ /**
370
+ * How the runtime reads content packs: a URL per file, and the release that names the packs (or where
371
+ * to read one). The host owns its base and the way a version is encoded in it, so the protocol never
372
+ * builds a release-relative path — it knows a pack's name and its files (`pack.json`, `<name>.pck`,
373
+ * `files/<product>`), and asks.
374
+ */
372
375
  export interface PackSource {
373
- /** Where a release document and its packs are read (a CDN prefix, or a dev server's directory). */
374
- at?: (path: string) => string;
376
+ /** A pack's file, as a fetchable URL or a local path — how the host addresses a version is its own. */
377
+ at?: (pack: string, version: string, file: string) => string;
375
378
  /** The app's own pack, configured by the bake's own code: always mounted, its bytes bundled. */
376
379
  boot?: MountedPack;
380
+ /**
381
+ * The packs to mount. The app's own dependencies (pinned by its lockfile) are one way: there is no
382
+ * release document to fetch, and the versions the app was built and tested with are the ones it
383
+ * mounts. A URL is the other: mount what a release document names — a bake's `release.json`, a CDN's.
384
+ */
385
+ release?: Release | string;
377
386
  }
378
387
 
379
388
  let packSource: PackSource = {};
@@ -386,11 +395,8 @@ export function configurePacks(source: PackSource): void {
386
395
  packSource = { ...packSource, ...source };
387
396
  }
388
397
 
389
- /** Reads a pack document: a URL (a release) over the network, anything else off the filesystem. */
390
- async function fetchPack(path: string): Promise<string> {
391
- const at = packSource.at;
392
- if (!at) throw new Error('godot-assets: no pack source — call configurePacks({ at }) first.');
393
- const url = at(path);
398
+ /** Reads a document: a URL over the network, anything else off the filesystem. */
399
+ async function readText(url: string): Promise<string> {
394
400
  if (/^https?:/i.test(url)) {
395
401
  const res = await fetch(url);
396
402
  if (!res.ok) throw new Error(`godot-assets: ${url} (${res.status})`);
@@ -402,24 +408,27 @@ async function fetchPack(path: string): Promise<string> {
402
408
  return text;
403
409
  }
404
410
 
411
+ /** The release to mount: the packs the app pinned, or the document `configurePacks` was pointed at. */
412
+ async function mountedRelease(): Promise<Release> {
413
+ const pinned = packSource.release;
414
+ if (!pinned) throw new Error('godot-assets: no release — configurePacks({ release }) with the packs to mount or its URL.');
415
+ return typeof pinned === 'string' ? parseRelease(await readText(pinned)) : pinned;
416
+ }
417
+
405
418
  /**
406
419
  * A resolved entry's files, as the loader stages them: the app's own pack addresses a product from its
407
- * bundled URLs, a release pack from the version-addressed path the entry was resolved against. This is
408
- * the addressing the protocol deliberately does not know — which is why a resolution returns packs and
409
- * file names rather than URLs.
420
+ * bundled URLs, a mounted pack from the host's addressing. This is the addressing the protocol
421
+ * deliberately does not know — which is why a resolution returns packs and file names, not URLs.
410
422
  */
411
423
  function stagedFiles(
412
424
  plan: ResolvedEntry,
413
- release: Release,
414
425
  boot: MountedPack | undefined,
415
- at: (path: string) => string,
426
+ fileUrl: (pack: string, file: string) => string,
416
427
  ): StagedFiles {
417
428
  const files: StagedFiles = {};
418
429
  for (const { sidecar, content, product, pack, file } of plan.files) {
419
430
  files[sidecar] = { content };
420
- files[product] = {
421
- path: pack === null ? boot!.url(file) : at(packPath(pack, release[pack].version, `${FILES_DIR}/${file}`)),
422
- };
431
+ files[product] = { path: pack === null ? boot!.url(file) : fileUrl(pack, `${FILES_DIR}/${file}`) };
423
432
  }
424
433
  return files;
425
434
  }
@@ -436,24 +445,31 @@ export async function loadPackEntry<C extends ResourceConstructor>(
436
445
  ): Promise<InstanceType<C>> {
437
446
  const at = packSource.at;
438
447
  if (!at) throw new Error('godot-assets: no pack source — call configurePacks({ at }) first.');
448
+ const release = await mountedRelease();
439
449
  // Mounting needs only the release — a pack's `.pck` is `<name>.pck` by convention — so the closure's
440
- // packs are mounted while their documents are fetched, and only staging waits on those.
441
- const release = parseRelease(await fetchPack(RELEASE));
450
+ // packs are mounted while their documents are read, and only staging waits on those.
442
451
  const names = mountClosure(release, pack);
443
- const pckUrl = (name: string) => at(packPath(name, release[name].version, pckOf(name)));
452
+ const url = (name: string, file: string) => at(name, release[name].version, file);
444
453
  const boot = packSource.boot;
445
- const mounts = [...(boot ? [boot.pck] : []), ...names.map(pckUrl)].map((url) => loadAssetPack(url));
454
+ const mounts = [...(boot ? [boot.pck] : []), ...names.map((name) => url(name, pckOf(name)))].map((pck) =>
455
+ loadAssetPack(pck),
456
+ );
446
457
  const docs = Object.fromEntries(
447
458
  await Promise.all(
448
459
  names.map(async (name): Promise<[string, PackDoc]> => {
449
- const { version } = release[name];
450
- return [name, parseDoc(await fetchPack(packPath(name, version, PACK_DOC)))];
460
+ const doc = parseDoc(await readText(url(name, PACK_DOC)));
461
+ // A base that serves a version it wasn't asked for is the one thing a client cannot see:
462
+ // production's URLs are immutable, and a dev server pointed at a local bake serves content
463
+ // at another version on purpose. Say so; the load still works.
464
+ if (doc.version !== release[name].version) {
465
+ console.warn(`godot-assets: ${name} ${release[name].version} asked, ${doc.version ?? 'no version'} served`);
466
+ }
467
+ return [name, doc];
451
468
  }),
452
469
  ),
453
470
  );
454
471
  const plan = resolveEntry(release, docs, pack, entry, boot?.doc);
455
472
  // The boot is a mounted pack like any other, so an entry borrows its loose files too.
456
- const files = stagedFiles(plan, release, boot, at);
457
473
  await Promise.all(mounts);
458
- return loadAsset(plan.res, cls, files).load;
474
+ return loadAsset(plan.res, cls, stagedFiles(plan, boot, url)).load;
459
475
  }
package/src/pack.ts CHANGED
@@ -87,11 +87,6 @@ export function mountClosure(release: Release, entry: string): string[] {
87
87
  return out;
88
88
  }
89
89
 
90
- /** A pack's file, relative to a release base: `<name>/<version>/<file>` (version-addressed, immutable). */
91
- export function packPath(name: string, version: string, file: string): string {
92
- return `${name}/${version}/${file}`;
93
- }
94
-
95
90
  /** The `.pck` beside a pack's document: its name's last segment (a scoped name stays flat). The one
96
91
  * convention the bake's writer and the runtime's reader share — mounting resolves it from the release
97
92
  * alone, so it never waits on the document. */
package/src/preload.ts CHANGED
@@ -1,165 +1,164 @@
1
- /**********************************************************************
2
- Copyright (c) Vladimir Davidovich. All rights reserved.
3
- ***********************************************************************/
4
-
5
- import { existsSync, readFileSync } from 'node:fs';
6
- import { dirname, resolve } from 'node:path';
7
- import { plugin } from 'bun';
8
- import { classOf, moduleExtensions } from './formats.ts';
9
- import { BOOT_MANIFEST, BOOT_MODULE, PACK_DIR, PACK_DIR_ENV, PACK_DOC, RELEASE, descriptorOf, packEntries, stagedKeys, type PackDoc, type PackEntry } from './pack.ts';
10
- import { assetModule, packModule, type ServedAsset } from './serve.ts';
11
- import type { BunPlugin } from 'bun';
12
-
13
- // Registers the godot-assets plugin: importing a Godot source asset becomes a module that stages what
14
- // the source needs and loads it (default export = the load promise, `materialize` = its files staged).
15
- //
16
- // What a source needs comes from the bake (`@ringozz/godot/pack`), never from scanning source text:
17
- // `dump-deps.gd` walked the project at bake time and wrote, per source, the loose (WebP-backed)
18
- // texture sources the wasm engine cannot decode — and the descriptor of every loose file the app's
19
- // pack owns. A packed source resolves inside the mounted `.pck`; only its loose textures are staged
20
- // per file, each with the `.import` text and product `descriptorOf` reads from the same sidecar the
21
- // bake read.
22
- //
23
- // The bake's own pack is the module it generated beside the packs (`boot-pack.js`): its `.pck`, the
24
- // config the runtime stages content packs from, and the mount every asset module waits for. So
25
- // importing any asset is what mounts the app's pack, and the config an app still supplies
26
- // (`configurePacks({ at })`, where a content release is read) merges with it in either order.
27
- //
28
- // The load promise persists on `import.meta.hot.data` (carried across HMR re-evaluations) so `use()`
29
- // never re-suspends; a rejected load is evicted so the next evaluation retries. The default export is
30
- // the single plugin — registered globally via bunfig preload and loaded by `[serve.static] plugins`.
31
-
32
- const ROOT = process.cwd();
33
- const NS = 'godot';
34
-
35
- const ASSET_RE = new RegExp(`\\.(${moduleExtensions.join('|')})$`, 'i');
36
-
37
- const resOf = (abs: string) => 'res://' + abs.slice(ROOT.length + 1).replaceAll('\\', '/');
38
-
39
- const readJson = <T>(file: string): T | null =>
40
- existsSync(file) ? (JSON.parse(readFileSync(file, 'utf8')) as T) : null;
41
-
42
- /** Where the boot pack was baked — `godot pack`'s `--out` ({@link PACK_DIR_ENV} overrides for a host). */
43
- const DIR = resolve(ROOT, process.env[PACK_DIR_ENV] ?? PACK_DIR);
44
- const MANIFEST = readJson<PackDoc>(resolve(DIR, BOOT_MANIFEST));
45
- /** The bake's generated boot module (`{ mounting }` + its config); null when unbaked. */
46
- const BOOT = MANIFEST ? resolve(DIR, BOOT_MODULE) : null;
47
-
48
- /**
49
- * A content pack's entries, from the bake's own documents — `release.json` and each pack's emitted
50
- * manifest, the same shapes the runtime fetches. A pack carries no entry module: the plugin serves one
51
- * built from these, so an entry, its `res://` path and its class come from one declaration — the rule
52
- * the bake emits the pack's types from as well (`packEntries`).
53
- */
54
- const PACK_ENTRIES = readPacks();
55
-
56
- function readPacks(): Map<string, PackEntry[]> {
57
- const packs = new Map<string, PackEntry[]>();
58
- const release = readJson<Record<string, { version?: string }>>(resolve(DIR, RELEASE));
59
- for (const [pack, { version }] of Object.entries(release ?? {})) {
60
- if (!version) continue;
61
- const entries = packEntries(readJson<PackDoc>(resolve(DIR, pack, version, PACK_DOC))?.exports);
62
- if (entries.length) packs.set(pack, entries);
63
- }
64
- return packs;
65
- }
66
-
67
- /**
68
- * The loose texture sources one asset stages: what the bake's manifest recorded for it, or itself when
69
- * it *is* a loose texture — a source with an `.import` sidecar is excluded from the pack, so a texture
70
- * imported on its own has no packed source to name it (the same rule `dump-deps.gd` classifies by).
71
- */
72
- function needsOf(abs: string): string[] {
73
- const res = resOf(abs);
74
- const baked = MANIFEST?.needs[res];
75
- if (baked) return baked;
76
- return existsSync(abs + '.import') ? [res] : [];
77
- }
78
-
79
- /**
80
- * A loose file's staging descriptor: the bake's own (its manifest carries every loose file the app's
81
- * pack owns), or its sidecar when the bake never saw it — which is what keeps the plugin and a content
82
- * pack's runtime staging on the same rule, `descriptorOf`.
83
- */
84
- function descriptorFor(loose: string): { path: string; import: string } | null {
85
- const baked = MANIFEST?.stage[loose];
86
- if (baked) return baked;
87
- const sidecar = resolve(ROOT, loose.slice('res://'.length)) + '.import';
88
- return existsSync(sidecar) ? descriptorOf(readFileSync(sidecar, 'utf8')) : null;
89
- }
90
-
91
- /**
92
- * One source's module, from the bake's own records: the loose textures the bake recorded for it (`needs`
93
- * / `stage`, or its sidecar when the bake never saw it), each as the absolute path the bundler imports
94
- * plus the two keys it stages under. Every other decision is the module text's (`serve.ts`).
95
- */
96
- function servedAsset(abs: string): ServedAsset {
97
- return {
98
- res: resOf(abs),
99
- // The filter is `moduleExtensions`, so a path that reached here has a class.
100
- cls: classOf(abs)!,
101
- boot: BOOT,
102
- files: needsOf(abs).flatMap((loose) => {
103
- const descriptor = descriptorFor(loose);
104
- if (!descriptor) return [];
105
- const { sidecar, product } = stagedKeys(loose, descriptor);
106
- return [
107
- {
108
- specifier: resolve(ROOT, descriptor.path.replace(/^res:\/\//, '')),
109
- sidecar,
110
- content: descriptor.import,
111
- product,
112
- },
113
- ];
114
- }),
115
- };
116
- }
117
-
118
- /**
119
- * Exactly the pack names the bake declares — nothing else is touched. A wider filter would shadow
120
- * resolutions Bun makes itself (it injects a `react-refresh` import in dev, and that import must fall
121
- * through to it, not to a plugin that declines).
122
- */
123
- const PACK_RE = PACK_ENTRIES.size
124
- ? new RegExp(`^(${[...PACK_ENTRIES.keys()].map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})$`)
125
- : null;
126
-
127
- const assetPlugin: BunPlugin = {
128
- name: 'godot-assets',
129
- setup(build) {
130
- build.onResolve({ filter: ASSET_RE }, (args) => {
131
- // Only JS module imports of Godot source assets become `godot` modules.
132
- // `Bun.build` reports JS imports as `import-statement` (and
133
- // `dynamic-import`/`require-*`) but HTML asset references (<link>/<img>)
134
- // and CSS url() refs as `internal`/`url-token` — those fall through so
135
- // Bun copies/hashes the file normally. The runtime preload (bun run /
136
- // bun test) reports JS imports with no kind (`undefined`), so treat that
137
- // as a JS import too.
138
- if (
139
- args.kind !== undefined &&
140
- args.kind !== 'import-statement' &&
141
- args.kind !== 'dynamic-import' &&
142
- args.kind !== 'require-call' &&
143
- args.kind !== 'require-resolve'
144
- ) {
145
- return;
146
- }
147
- const base = args.importer ? dirname(args.importer) : (args.resolveDir ?? ROOT);
148
- return { path: resolve(base, args.path), namespace: NS };
149
- });
150
- // A pack's own module (the pack carries no entry file, so the plugin serves it, built from the
151
- // bake's manifests). The filter names the packs exactly: anything else resolves as usual.
152
- if (PACK_RE) {
153
- build.onResolve({ filter: PACK_RE }, (args) => ({ path: args.path, namespace: NS }));
154
- }
155
- build.onLoad({ filter: /.*/, namespace: NS }, (args) => ({
156
- contents: PACK_ENTRIES.has(args.path)
157
- ? packModule(args.path, PACK_ENTRIES.get(args.path)!)
158
- : assetModule(servedAsset(args.path)),
159
- loader: 'js',
160
- }));
161
- },
162
- };
163
-
164
- export default assetPlugin;
165
- plugin(assetPlugin);
1
+ /**********************************************************************
2
+ Copyright (c) Vladimir Davidovich. All rights reserved.
3
+ ***********************************************************************/
4
+
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { dirname, resolve } from 'node:path';
7
+ import { plugin } from 'bun';
8
+ import { classOf, moduleExtensions } from './formats.ts';
9
+ import { BOOT_MANIFEST, BOOT_MODULE, PACK_DIR, PACK_DIR_ENV, PACK_DOC, RELEASE, descriptorOf, packEntries, stagedKeys, type PackDoc, type PackEntry } from './pack.ts';
10
+ import { assetModule, packModule, type ServedAsset } from './serve.ts';
11
+ import type { BunPlugin } from 'bun';
12
+
13
+ // Registers the godot-assets plugin: importing a Godot source asset becomes a module that stages what
14
+ // the source needs and loads it (default export = the load promise, `materialize` = its files staged).
15
+ //
16
+ // What a source needs comes from the bake (`@ringozz/godot/pack`), never from scanning source text:
17
+ // `dump-deps.gd` walked the project at bake time and wrote, per source, the loose (WebP-backed)
18
+ // texture sources the wasm engine cannot decode — and the descriptor of every loose file the app's
19
+ // pack owns. A packed source resolves inside the mounted `.pck`; only its loose textures are staged
20
+ // per file, each with the `.import` text and product `descriptorOf` reads from the same sidecar the
21
+ // bake read.
22
+ //
23
+ // The bake's own pack is the module it generated beside the packs (`boot-pack.js`): its `.pck`, the
24
+ // config the runtime stages content packs from, and the mount every asset module waits for. So
25
+ // importing any asset is what mounts the app's pack, and the config an app still supplies
26
+ // (`configurePacks({ at })`, where a content release is read) merges with it in either order.
27
+ //
28
+ // The load promise persists on `import.meta.hot.data` (carried across HMR re-evaluations) so `use()`
29
+ // never re-suspends; a rejected load is evicted so the next evaluation retries. The default export is
30
+ // the single plugin — registered globally via bunfig preload and loaded by `[serve.static] plugins`.
31
+
32
+ const ROOT = process.cwd();
33
+ const NS = 'godot';
34
+
35
+ const ASSET_RE = new RegExp(`\\.(${moduleExtensions.join('|')})$`, 'i');
36
+
37
+ const resOf = (abs: string) => 'res://' + abs.slice(ROOT.length + 1).replaceAll('\\', '/');
38
+
39
+ const readJson = <T>(file: string): T | null =>
40
+ existsSync(file) ? (JSON.parse(readFileSync(file, 'utf8')) as T) : null;
41
+
42
+ /** Where the boot pack was baked — `godot pack`'s `--out` ({@link PACK_DIR_ENV} overrides for a host). */
43
+ const DIR = resolve(ROOT, process.env[PACK_DIR_ENV] ?? PACK_DIR);
44
+ const MANIFEST = readJson<PackDoc>(resolve(DIR, BOOT_MANIFEST));
45
+ /** The bake's generated boot module (`{ mounting }` + its config); null when unbaked. */
46
+ const BOOT = MANIFEST ? resolve(DIR, BOOT_MODULE) : null;
47
+
48
+ /**
49
+ * A content pack's entries, from the bake's own documents — `release.json` and each pack's emitted
50
+ * manifest, the same shapes the runtime fetches. A pack carries no entry module: the plugin serves one
51
+ * built from these, so an entry, its `res://` path and its class come from one declaration — the rule
52
+ * the bake emits the pack's types from as well (`packEntries`).
53
+ */
54
+ const PACK_ENTRIES = readPacks();
55
+
56
+ function readPacks(): Map<string, PackEntry[]> {
57
+ const packs = new Map<string, PackEntry[]>();
58
+ const release = readJson<Record<string, unknown>>(resolve(DIR, RELEASE));
59
+ for (const pack of Object.keys(release ?? {})) {
60
+ const entries = packEntries(readJson<PackDoc>(resolve(DIR, pack, PACK_DOC))?.exports);
61
+ if (entries.length) packs.set(pack, entries);
62
+ }
63
+ return packs;
64
+ }
65
+
66
+ /**
67
+ * The loose texture sources one asset stages: what the bake's manifest recorded for it, or itself when
68
+ * it *is* a loose texture — a source with an `.import` sidecar is excluded from the pack, so a texture
69
+ * imported on its own has no packed source to name it (the same rule `dump-deps.gd` classifies by).
70
+ */
71
+ function needsOf(abs: string): string[] {
72
+ const res = resOf(abs);
73
+ const baked = MANIFEST?.needs[res];
74
+ if (baked) return baked;
75
+ return existsSync(abs + '.import') ? [res] : [];
76
+ }
77
+
78
+ /**
79
+ * A loose file's staging descriptor: the bake's own (its manifest carries every loose file the app's
80
+ * pack owns), or its sidecar when the bake never saw it — which is what keeps the plugin and a content
81
+ * pack's runtime staging on the same rule, `descriptorOf`.
82
+ */
83
+ function descriptorFor(loose: string): { path: string; import: string } | null {
84
+ const baked = MANIFEST?.stage[loose];
85
+ if (baked) return baked;
86
+ const sidecar = resolve(ROOT, loose.slice('res://'.length)) + '.import';
87
+ return existsSync(sidecar) ? descriptorOf(readFileSync(sidecar, 'utf8')) : null;
88
+ }
89
+
90
+ /**
91
+ * One source's module, from the bake's own records: the loose textures the bake recorded for it (`needs`
92
+ * / `stage`, or its sidecar when the bake never saw it), each as the absolute path the bundler imports
93
+ * plus the two keys it stages under. Every other decision is the module text's (`serve.ts`).
94
+ */
95
+ function servedAsset(abs: string): ServedAsset {
96
+ return {
97
+ res: resOf(abs),
98
+ // The filter is `moduleExtensions`, so a path that reached here has a class.
99
+ cls: classOf(abs)!,
100
+ boot: BOOT,
101
+ files: needsOf(abs).flatMap((loose) => {
102
+ const descriptor = descriptorFor(loose);
103
+ if (!descriptor) return [];
104
+ const { sidecar, product } = stagedKeys(loose, descriptor);
105
+ return [
106
+ {
107
+ specifier: resolve(ROOT, descriptor.path.replace(/^res:\/\//, '')),
108
+ sidecar,
109
+ content: descriptor.import,
110
+ product,
111
+ },
112
+ ];
113
+ }),
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Exactly the pack names the bake declares — nothing else is touched. A wider filter would shadow
119
+ * resolutions Bun makes itself (it injects a `react-refresh` import in dev, and that import must fall
120
+ * through to it, not to a plugin that declines).
121
+ */
122
+ const PACK_RE = PACK_ENTRIES.size
123
+ ? new RegExp(`^(${[...PACK_ENTRIES.keys()].map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})$`)
124
+ : null;
125
+
126
+ const assetPlugin: BunPlugin = {
127
+ name: 'godot-assets',
128
+ setup(build) {
129
+ build.onResolve({ filter: ASSET_RE }, (args) => {
130
+ // Only JS module imports of Godot source assets become `godot` modules.
131
+ // `Bun.build` reports JS imports as `import-statement` (and
132
+ // `dynamic-import`/`require-*`) but HTML asset references (<link>/<img>)
133
+ // and CSS url() refs as `internal`/`url-token` — those fall through so
134
+ // Bun copies/hashes the file normally. The runtime preload (bun run /
135
+ // bun test) reports JS imports with no kind (`undefined`), so treat that
136
+ // as a JS import too.
137
+ if (
138
+ args.kind !== undefined &&
139
+ args.kind !== 'import-statement' &&
140
+ args.kind !== 'dynamic-import' &&
141
+ args.kind !== 'require-call' &&
142
+ args.kind !== 'require-resolve'
143
+ ) {
144
+ return;
145
+ }
146
+ const base = args.importer ? dirname(args.importer) : (args.resolveDir ?? ROOT);
147
+ return { path: resolve(base, args.path), namespace: NS };
148
+ });
149
+ // A pack's own module (the pack carries no entry file, so the plugin serves it, built from the
150
+ // bake's manifests). The filter names the packs exactly: anything else resolves as usual.
151
+ if (PACK_RE) {
152
+ build.onResolve({ filter: PACK_RE }, (args) => ({ path: args.path, namespace: NS }));
153
+ }
154
+ build.onLoad({ filter: /.*/, namespace: NS }, (args) => ({
155
+ contents: PACK_ENTRIES.has(args.path)
156
+ ? packModule(args.path, PACK_ENTRIES.get(args.path)!)
157
+ : assetModule(servedAsset(args.path)),
158
+ loader: 'js',
159
+ }));
160
+ },
161
+ };
162
+
163
+ export default assetPlugin;
164
+ plugin(assetPlugin);
package/src/serve.ts CHANGED
@@ -1,67 +1,67 @@
1
- /**********************************************************************
2
- Copyright (c) Vladimir Davidovich. All rights reserved.
3
- ***********************************************************************/
4
-
5
- // The source text the preload plugin serves, as a virtual module per import: one for a Godot source
6
- // asset (the loose textures it stages, then the load), one for a content pack (a named export per entry
7
- // it declares). Pure — a module's text is a function of the facts the plugin resolved — which is what
8
- // makes the one part of the plugin that decides what an import becomes reachable without a bake, a
9
- // bundler or an engine. The plugin resolves those facts (`preload.ts`); nothing here touches a disk.
10
-
11
- import { entryId, type PackEntry } from './pack.ts';
12
-
13
- /** The facts the plugin resolved for one source asset, as the module it serves reads them. */
14
- export interface ServedAsset {
15
- /** The source's `res://` path — what the module loads. */
16
- res: string;
17
- /** The class it loads as. */
18
- cls: string;
19
- /**
20
- * One entry per loose texture the source needs: the file to bundle (an absolute path, so the host can
21
- * transcode it), and the two keys it stages under. The sidecar text is inlined rather than imported
22
- * as text — Bun's `with { type: 'text' }` resolves to a null/default under the dev server's HMR
23
- * wrapper for a module's own path.
24
- */
25
- files: Array<{ specifier: string; sidecar: string; content: string; product: string }>;
26
- /** The boot module every asset module waits for (`{ mounting }`); `null` when the project is unbaked. */
27
- boot: string | null;
28
- }
29
-
30
- /**
31
- * One source's module: its loose textures as bundled files, and the load. The boot module supplies the
32
- * mount to wait for, so the pack's remaps are in place before anything resolves — and importing it is
33
- * also what mounts the boot pack.
34
- */
35
- export function assetModule({ res, cls, files, boot }: ServedAsset): string {
36
- const imports = [
37
- `import { loadAsset } from '@ringozz/godot/load';`,
38
- `import { ${cls} } from '@ringozz/godot/${cls}';`,
39
- ];
40
- const map: string[] = [];
41
- files.forEach(({ specifier, sidecar, content, product }, n) => {
42
- imports.push(`import f${n} from ${JSON.stringify(specifier)} with { type: 'file' };`);
43
- map.push(`\t${JSON.stringify(sidecar)}: { content: ${JSON.stringify(content)} },`);
44
- map.push(`\t${JSON.stringify(product)}: { path: f${n} },`);
45
- });
46
- if (boot) imports.push(`import { mounting } from ${JSON.stringify(boot)};`);
47
- const staged = map.length ? `const files = {\n${map.join('\n')}\n};` : 'const files = {};';
48
- const load = `const { materialize, load } = loadAsset(${JSON.stringify(res)}, ${cls}, files, import.meta.hot.data, ${boot ? 'mounting' : 'undefined'});`;
49
- return `${imports.join('\n')}\n${staged}\n${load}\nexport { materialize, load as default };\n`;
50
- }
51
-
52
- /**
53
- * A content pack's module: one named export per entry its document declares, each loading through
54
- * `loadPackEntry`, so a client resolves the entry by name from the document it fetched. Nothing about
55
- * the pack is baked in — no entry file, no path — and the class is the same format-table rule every
56
- * asset import uses.
57
- */
58
- export function packModule(pack: string, entries: PackEntry[]): string {
59
- const imports = ["import { loadPackEntry } from '@ringozz/godot/load';"];
60
- for (const cls of new Set(entries.map((e) => e.cls))) {
61
- imports.push(`import { ${cls} } from '@ringozz/godot/${cls}';`);
62
- }
63
- const lines = entries.map(
64
- (e) => `export const ${entryId(e.entry)} = loadPackEntry(${JSON.stringify(pack)}, ${JSON.stringify(e.entry)}, ${e.cls});`,
65
- );
66
- return `${imports.join('\n')}\n\n${lines.join('\n')}\n`;
67
- }
1
+ /**********************************************************************
2
+ Copyright (c) Vladimir Davidovich. All rights reserved.
3
+ ***********************************************************************/
4
+
5
+ // The source text the preload plugin serves, as a virtual module per import: one for a Godot source
6
+ // asset (the loose textures it stages, then the load), one for a content pack (a named export per entry
7
+ // it declares). Pure — a module's text is a function of the facts the plugin resolved — which is what
8
+ // makes the one part of the plugin that decides what an import becomes reachable without a bake, a
9
+ // bundler or an engine. The plugin resolves those facts (`preload.ts`); nothing here touches a disk.
10
+
11
+ import { entryId, type PackEntry } from './pack.ts';
12
+
13
+ /** The facts the plugin resolved for one source asset, as the module it serves reads them. */
14
+ export interface ServedAsset {
15
+ /** The source's `res://` path — what the module loads. */
16
+ res: string;
17
+ /** The class it loads as. */
18
+ cls: string;
19
+ /**
20
+ * One entry per loose texture the source needs: the file to bundle (an absolute path, so the host can
21
+ * transcode it), and the two keys it stages under. The sidecar text is inlined rather than imported
22
+ * as text — Bun's `with { type: 'text' }` resolves to a null/default under the dev server's HMR
23
+ * wrapper for a module's own path.
24
+ */
25
+ files: Array<{ specifier: string; sidecar: string; content: string; product: string }>;
26
+ /** The boot module every asset module waits for (`{ mounting }`); `null` when the project is unbaked. */
27
+ boot: string | null;
28
+ }
29
+
30
+ /**
31
+ * One source's module: its loose textures as bundled files, and the load. The boot module supplies the
32
+ * mount to wait for, so the pack's remaps are in place before anything resolves — and importing it is
33
+ * also what mounts the boot pack.
34
+ */
35
+ export function assetModule({ res, cls, files, boot }: ServedAsset): string {
36
+ const imports = [
37
+ `import { loadAsset } from '@ringozz/godot/load';`,
38
+ `import { ${cls} } from '@ringozz/godot/${cls}';`,
39
+ ];
40
+ const map: string[] = [];
41
+ files.forEach(({ specifier, sidecar, content, product }, n) => {
42
+ imports.push(`import f${n} from ${JSON.stringify(specifier)} with { type: 'file' };`);
43
+ map.push(`\t${JSON.stringify(sidecar)}: { content: ${JSON.stringify(content)} },`);
44
+ map.push(`\t${JSON.stringify(product)}: { path: f${n} },`);
45
+ });
46
+ if (boot) imports.push(`import { mounting } from ${JSON.stringify(boot)};`);
47
+ const staged = map.length ? `const files = {\n${map.join('\n')}\n};` : 'const files = {};';
48
+ const load = `const { materialize, load } = loadAsset(${JSON.stringify(res)}, ${cls}, files, import.meta.hot.data, ${boot ? 'mounting' : 'undefined'});`;
49
+ return `${imports.join('\n')}\n${staged}\n${load}\nexport { materialize, load as default };\n`;
50
+ }
51
+
52
+ /**
53
+ * A content pack's module: one named export per entry its document declares, each loading through
54
+ * `loadPackEntry`, so a client resolves the entry by name from the document it fetched. Nothing about
55
+ * the pack is baked in — no entry file, no path — and the class is the same format-table rule every
56
+ * asset import uses.
57
+ */
58
+ export function packModule(pack: string, entries: PackEntry[]): string {
59
+ const imports = ["import { loadPackEntry } from '@ringozz/godot/load';"];
60
+ for (const cls of new Set(entries.map((e) => e.cls))) {
61
+ imports.push(`import { ${cls} } from '@ringozz/godot/${cls}';`);
62
+ }
63
+ const lines = entries.map(
64
+ (e) => `export const ${entryId(e.entry)} = loadPackEntry(${JSON.stringify(pack)}, ${JSON.stringify(e.entry)}, ${e.cls});`,
65
+ );
66
+ return `${imports.join('\n')}\n\n${lines.join('\n')}\n`;
67
+ }