@linxin666/dsh-pet 0.1.14 → 0.1.16

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 (90) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +73 -38
  3. package/README.zh.md +91 -55
  4. package/assets/whale/pet.json +12 -1
  5. package/cordis.patch.yml +4 -4
  6. package/lib/client.js +296 -347
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +975 -317
  9. package/lib/invariant.js +1 -1
  10. package/lib/{state-P-wsVJ6O.js → state-CFyJv0sQ.js} +15 -1
  11. package/lib/types/affinity.d.ts +15 -0
  12. package/lib/types/affinity.d.ts.map +1 -1
  13. package/lib/types/affinity.js +14 -0
  14. package/lib/types/client/PetDockEntry.d.ts +14 -12
  15. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  16. package/lib/types/client/PetDockEntry.js +13 -8
  17. package/lib/types/client/PetSettingsCard.d.ts +22 -9
  18. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/PetSettingsCard.js +42 -7
  20. package/lib/types/client/{WhalePet.d.ts → PetSprite.d.ts} +21 -20
  21. package/lib/types/client/PetSprite.d.ts.map +1 -0
  22. package/lib/types/client/{WhalePet.js → PetSprite.js} +44 -67
  23. package/lib/types/client/PluginSettingsCard.d.ts +26 -11
  24. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  25. package/lib/types/client/PluginSettingsCard.js +30 -8
  26. package/lib/types/client/index.d.ts +11 -8
  27. package/lib/types/client/index.d.ts.map +1 -1
  28. package/lib/types/client/index.js +32 -14
  29. package/lib/types/client/locales.d.ts +10 -10
  30. package/lib/types/client/locales.js +10 -10
  31. package/lib/types/client/pet-store.d.ts +5 -0
  32. package/lib/types/client/pet-store.d.ts.map +1 -1
  33. package/lib/types/client/pet-store.js +4 -0
  34. package/lib/types/client/settings-form.d.ts +76 -5
  35. package/lib/types/client/settings-form.d.ts.map +1 -1
  36. package/lib/types/client/settings-form.js +97 -20
  37. package/lib/types/client/spritesheet.d.ts +14 -48
  38. package/lib/types/client/spritesheet.d.ts.map +1 -1
  39. package/lib/types/client/spritesheet.js +13 -81
  40. package/lib/types/dsh-home.d.ts +17 -0
  41. package/lib/types/dsh-home.d.ts.map +1 -0
  42. package/lib/types/dsh-home.js +34 -0
  43. package/lib/types/event-projection.d.ts +36 -0
  44. package/lib/types/event-projection.d.ts.map +1 -0
  45. package/lib/types/event-projection.js +98 -0
  46. package/lib/types/index.d.ts +24 -11
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +53 -33
  49. package/lib/types/ledger.d.ts +79 -0
  50. package/lib/types/ledger.d.ts.map +1 -0
  51. package/lib/types/ledger.js +146 -0
  52. package/lib/types/persist.d.ts +15 -4
  53. package/lib/types/persist.d.ts.map +1 -1
  54. package/lib/types/persist.js +39 -8
  55. package/lib/types/registry.d.ts +158 -0
  56. package/lib/types/registry.d.ts.map +1 -0
  57. package/lib/types/registry.js +276 -0
  58. package/lib/types/routes.d.ts +10 -10
  59. package/lib/types/routes.d.ts.map +1 -1
  60. package/lib/types/routes.js +169 -51
  61. package/lib/types/service.d.ts +59 -57
  62. package/lib/types/service.d.ts.map +1 -1
  63. package/lib/types/service.js +111 -211
  64. package/package.json +2 -2
  65. package/src/affinity.ts +33 -0
  66. package/src/client/PetDockEntry.tsx +34 -26
  67. package/src/client/PetSettingsCard.tsx +67 -22
  68. package/src/client/{WhalePet.test.tsx → PetSprite.test.tsx} +56 -8
  69. package/src/client/{WhalePet.tsx → PetSprite.tsx} +55 -73
  70. package/src/client/PluginSettingsCard.tsx +90 -18
  71. package/src/client/index.ts +38 -16
  72. package/src/client/locales.ts +10 -10
  73. package/src/client/pet-store.ts +9 -0
  74. package/src/client/settings-card.module.css +9 -0
  75. package/src/client/settings-form.ts +153 -19
  76. package/src/client/spritesheet.ts +16 -92
  77. package/src/dsh-home.test.ts +35 -0
  78. package/src/dsh-home.ts +36 -0
  79. package/src/event-projection.ts +128 -0
  80. package/src/index.test.ts +23 -0
  81. package/src/index.ts +89 -45
  82. package/src/ledger.test.ts +69 -0
  83. package/src/ledger.ts +190 -0
  84. package/src/persist.test.ts +52 -3
  85. package/src/persist.ts +49 -11
  86. package/src/registry.test.ts +132 -0
  87. package/src/registry.ts +400 -0
  88. package/src/routes.ts +166 -53
  89. package/src/service.ts +153 -285
  90. package/lib/types/client/WhalePet.d.ts.map +0 -1
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Pet registry — the multi-pet contract. One pet is a directory holding a
3
+ * 'pet.json' manifest plus an atlas image; nothing else is required, and no
4
+ * host or client code changes when a pet is added. The registry scans three
5
+ * sources, later sources overriding earlier ones on an id collision:
6
+ *
7
+ * 1. the package's own 'assets' subdirectories (built-in pets);
8
+ * 2. '${CODEX_HOME:-~/.codex}/pets' subdirectories (hatch-pet custom pets);
9
+ * 3. 'PetConfig.pets' manifests composed by the embedding application
10
+ * (highest precedence).
11
+ *
12
+ * The manifest follows the Codex/hatch-pet contract (8 columns x 9 rows of
13
+ * 192x208 cells, the 9-state row order below). Legacy whale-girl manifests
14
+ * that only carry 'frames' keep working: geometry, per-row frame counts and
15
+ * per-track rhythm all fall back to the hatch-pet contract defaults, and the
16
+ * whale-girl manifest overrides its own durations.
17
+ * @module @linxin666/dsh-pet/registry
18
+ */
19
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ /** Fixed row order of the 9-state animation contract. */
24
+ export const PET_ROW_ORDER = [
25
+ 'idle',
26
+ 'running-right',
27
+ 'running-left',
28
+ 'waving',
29
+ 'jumping',
30
+ 'failed',
31
+ 'waiting',
32
+ 'running',
33
+ 'review',
34
+ ];
35
+ /** Atlas cell size in px (Codex/hatch-pet contract). */
36
+ export const DEFAULT_PET_CELL = { width: 192, height: 208 };
37
+ /** Columns per row (max frames per track). */
38
+ export const DEFAULT_PET_COLUMNS = 8;
39
+ /** Rows in the atlas (fixed by the animation contract). */
40
+ export const DEFAULT_PET_ROW_COUNT = 9;
41
+ /**
42
+ * Per-row used-column counts from the hatch-pet contract table. Manifests
43
+ * that carry no 'frames' field (the Codex custom-pet shape) resolve here.
44
+ */
45
+ export const DEFAULT_FRAME_COUNTS = [6, 8, 8, 4, 5, 8, 6, 6, 6];
46
+ /** Absolute package root, resolved from a module URL (lib/ or src/). */
47
+ export function petPackageRoot(importMetaUrl) {
48
+ return fileURLToPath(new URL('../', importMetaUrl));
49
+ }
50
+ /** Resolve the hatch-pet custom pets directory (CODEX_HOME or ~/.codex). */
51
+ export function codexPetsDir(env = process.env, home = homedir()) {
52
+ const raw = env.CODEX_HOME !== undefined && env.CODEX_HOME.trim() !== ''
53
+ ? env.CODEX_HOME.trim()
54
+ : join(home, '.codex');
55
+ const expanded = raw === '~'
56
+ ? home
57
+ : (raw.startsWith('~/') || raw.startsWith('~\\')) ? join(home, raw.slice(2)) : raw;
58
+ return join(expanded, 'pets');
59
+ }
60
+ /** Finite non-negative integer guard, else the fallback. */
61
+ function finiteInt(value, fallback, max) {
62
+ return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= max
63
+ ? value
64
+ : fallback;
65
+ }
66
+ /** Build the browser URL of one pet asset. */
67
+ function assetUrl(prefix, id, file) {
68
+ const path = file.split('/').filter(segment => segment !== '').join('/');
69
+ return prefix + '/' + encodeURIComponent(id) + '/' + path;
70
+ }
71
+ /** Default per-track rhythm (hatch-pet contract table). */
72
+ export const DEFAULT_TRACK_PATTERNS = {
73
+ idle: { durations: [280, 110, 110, 140, 140, 320], loop: true },
74
+ 'running-right': { durations: [120, 120, 120, 120, 120, 120, 120, 220], loop: true },
75
+ 'running-left': { durations: [120, 120, 120, 120, 120, 120, 120, 220], loop: true },
76
+ waving: { durations: [140, 140, 140, 280], loop: true },
77
+ jumping: { durations: [140, 140, 140, 140, 280], loop: false, fallback: 'idle' },
78
+ failed: { durations: [140, 140, 140, 140, 140, 140, 140, 240], loop: false, fallback: 'idle' },
79
+ waiting: { durations: [150, 150, 150, 150, 150, 260], loop: true },
80
+ running: { durations: [120, 120, 120, 120, 120, 220], loop: true },
81
+ review: { durations: [150, 150, 150, 150, 150, 280], loop: true },
82
+ };
83
+ /** Stable id charset: keeps asset URLs plain and filesystem-safe. */
84
+ const PET_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
85
+ /** Safe path-segment charset for atlas files. */
86
+ const PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/;
87
+ const PET_NAME_MAX_LENGTH = 80;
88
+ /**
89
+ * Normalize one parsed manifest into a renderable pet entry, or undefined
90
+ * (with a warning recorded) when the manifest violates the contract.
91
+ */
92
+ export function resolvePetManifest(raw, dir, options = {}) {
93
+ const { assetPrefix = '/pet', warnings = [] } = options;
94
+ const warn = (message) => { warnings.push(message); };
95
+ if (typeof raw !== 'object' || raw === null) {
96
+ warn('manifest is not an object');
97
+ return undefined;
98
+ }
99
+ const source = raw;
100
+ const id = typeof source.id === 'string' ? source.id.trim() : '';
101
+ if (!PET_ID_PATTERN.test(id)) {
102
+ warn('manifest id ' + JSON.stringify(String(source.id)) + ' is not a lowercase kebab id');
103
+ return undefined;
104
+ }
105
+ const displayName = typeof source.displayName === 'string' && source.displayName.trim() !== ''
106
+ ? source.displayName.trim().slice(0, PET_NAME_MAX_LENGTH)
107
+ : id;
108
+ const description = typeof source.description === 'string'
109
+ ? source.description.trim()
110
+ : '';
111
+ const spritesheet = typeof source.spritesheetPath === 'string' && source.spritesheetPath.trim() !== ''
112
+ ? source.spritesheetPath.trim()
113
+ : 'spritesheet.webp';
114
+ const spritesheetPath = spritesheet.split('/').filter(segment => segment !== '');
115
+ if (spritesheetPath.length === 0
116
+ || isAbsolute(spritesheet)
117
+ || spritesheet.includes('\\')
118
+ || spritesheetPath.some(segment => segment === '..' || !PATH_SEGMENT_PATTERN.test(segment))) {
119
+ warn('manifest spritesheetPath ' + JSON.stringify(spritesheet) + ' is not a safe relative path');
120
+ return undefined;
121
+ }
122
+ const rawCell = (typeof source.cell === 'object' && source.cell !== null ? source.cell : {});
123
+ const cell = {
124
+ width: finiteInt(rawCell.width, DEFAULT_PET_CELL.width, 2048),
125
+ height: finiteInt(rawCell.height, DEFAULT_PET_CELL.height, 2048),
126
+ };
127
+ const columns = finiteInt(source.columns, DEFAULT_PET_COLUMNS, 32);
128
+ const rows = DEFAULT_FRAME_COUNTS.map((fallback, index) => {
129
+ const value = Array.isArray(source.frames) ? source.frames[index] : undefined;
130
+ return finiteInt(value, fallback, columns);
131
+ });
132
+ const trackOverrides = (typeof source.tracks === 'object' && source.tracks !== null ? source.tracks : {});
133
+ const tracks = {};
134
+ for (const [row, animation] of PET_ROW_ORDER.entries()) {
135
+ const pattern = DEFAULT_TRACK_PATTERNS[animation];
136
+ const override = trackOverrides[animation];
137
+ const durations = Array.isArray(override?.durations) && override.durations.length > 0
138
+ ? override.durations.filter((value) => typeof value === 'number' && Number.isFinite(value) && value > 0)
139
+ : pattern.durations;
140
+ if (durations.length === 0) {
141
+ warn('manifest ' + id + ': track ' + animation + ' carries no usable durations');
142
+ return undefined;
143
+ }
144
+ const frameCount = Math.max(1, Math.min(rows[row], columns));
145
+ const sized = durations.length >= frameCount
146
+ ? durations.slice(0, frameCount)
147
+ : Array.from({ length: frameCount }, (_, index) => durations[index % durations.length]);
148
+ tracks[animation] = {
149
+ frames: Array.from({ length: frameCount }, (_, index) => index),
150
+ durations: sized,
151
+ loop: typeof override?.loop === 'boolean' ? override.loop : pattern.loop,
152
+ ...(override?.fallback === undefined
153
+ ? pattern.fallback === undefined ? {} : { fallback: pattern.fallback }
154
+ : PET_ROW_ORDER.includes(override.fallback)
155
+ ? { fallback: override.fallback }
156
+ : pattern.fallback === undefined ? {} : { fallback: pattern.fallback }),
157
+ };
158
+ }
159
+ return {
160
+ id,
161
+ displayName,
162
+ description,
163
+ cell,
164
+ columns,
165
+ rows,
166
+ tracks,
167
+ atlasUrl: assetUrl(assetPrefix, id, spritesheet),
168
+ manifestUrl: assetUrl(assetPrefix, id, 'pet.json'),
169
+ dir,
170
+ spritesheetPath: spritesheetPath.join('/'),
171
+ };
172
+ }
173
+ /** Scan one directory of pet folders; entries come back in name order. */
174
+ function scanPetDir(dir, options) {
175
+ if (!existsSync(dir))
176
+ return [];
177
+ let names = [];
178
+ try {
179
+ names = readdirSync(dir).filter(name => !name.startsWith('.'));
180
+ }
181
+ catch {
182
+ return [];
183
+ }
184
+ names.sort();
185
+ const entries = [];
186
+ for (const name of names) {
187
+ const manifestFile = join(dir, name, 'pet.json');
188
+ if (!existsSync(manifestFile))
189
+ continue;
190
+ const parsed = readPetJson(manifestFile, options.warnings);
191
+ if (parsed === undefined)
192
+ continue;
193
+ const entry = resolvePetManifest(parsed, join(dir, name), options);
194
+ if (entry !== undefined)
195
+ entries.push(entry);
196
+ }
197
+ return entries;
198
+ }
199
+ /** Read and parse one manifest file; undefined (warning recorded) on failure. */
200
+ function readPetJson(file, warnings) {
201
+ try {
202
+ return JSON.parse(readFileSync(file, 'utf8'));
203
+ }
204
+ catch (error) {
205
+ warnings?.push('skipping ' + file + ': ' + (error instanceof Error ? error.message : String(error)));
206
+ return undefined;
207
+ }
208
+ }
209
+ /**
210
+ * Load the pet registry: built-in 'assets/*' first, then the hatch-pet
211
+ * custom pets directory, then composed 'extra' manifests (each later source
212
+ * overrides an earlier one on id collision). The registry never throws on a
213
+ * bad manifest: it skips it and records a warning.
214
+ */
215
+ export function loadPetRegistry(options) {
216
+ const { packageRoot, assetPrefix = '/pet' } = options;
217
+ const warnings = [];
218
+ const byId = new Map();
219
+ const builtinIds = new Set();
220
+ for (const entry of scanPetDir(join(packageRoot, 'assets'), { assetPrefix, warnings })) {
221
+ if (byId.has(entry.id)) {
222
+ warnings.push('duplicate built-in pet id ' + entry.id + '; the first one wins');
223
+ continue;
224
+ }
225
+ byId.set(entry.id, entry);
226
+ builtinIds.add(entry.id);
227
+ }
228
+ const petsDir = options.petsDir ?? codexPetsDir();
229
+ if (petsDir !== '') {
230
+ for (const entry of scanPetDir(petsDir, { assetPrefix, warnings })) {
231
+ if (byId.has(entry.id))
232
+ warnings.push('custom pet ' + entry.id + ' overrides the built-in one');
233
+ byId.set(entry.id, entry);
234
+ }
235
+ }
236
+ for (const manifest of options.extra ?? []) {
237
+ const dir = manifest.spritesheetPath === undefined || isAbsolute(manifest.spritesheetPath)
238
+ ? join(packageRoot, 'assets', 'extra')
239
+ : dirname(resolve(packageRoot, manifest.spritesheetPath));
240
+ const entry = resolvePetManifest(manifest, dir, { assetPrefix, warnings });
241
+ if (entry === undefined)
242
+ continue;
243
+ if (byId.has(entry.id))
244
+ warnings.push('composed pet ' + entry.id + ' overrides an earlier registration');
245
+ byId.set(entry.id, entry);
246
+ }
247
+ const entries = [...byId.values()];
248
+ return {
249
+ entries,
250
+ warnings,
251
+ byId: (id) => byId.get(id),
252
+ defaultEntry: () => entries.find(entry => builtinIds.has(entry.id)) ?? entries[0],
253
+ };
254
+ }
255
+ /** Strip host-only fields, leaving the client-visible definition. */
256
+ export function petEntryView(entry) {
257
+ return {
258
+ id: entry.id,
259
+ displayName: entry.displayName,
260
+ description: entry.description,
261
+ cell: entry.cell,
262
+ columns: entry.columns,
263
+ rows: entry.rows,
264
+ tracks: entry.tracks,
265
+ atlasUrl: entry.atlasUrl,
266
+ manifestUrl: entry.manifestUrl,
267
+ };
268
+ }
269
+ /** The absolute file a pet's atlas resolves to (host asset route). */
270
+ export function petAtlasFile(entry) {
271
+ return join(entry.dir, entry.spritesheetPath);
272
+ }
273
+ /** The directory basename of one entry (legacy asset URL alias, e.g. whale). */
274
+ export function petDirAlias(entry) {
275
+ return basename(entry.dir);
276
+ }
@@ -1,22 +1,22 @@
1
1
  /**
2
2
  * Pet HTTP routes — the browser half talks to the host through plain
3
- * same-origin JSON endpoints (`/api/pet/*`) and loads the whale-girl atlas
4
- * from `/pet/whale/*`. The `/plugins/` endpoint only serves client bundles
5
- * and RPC domains are platform-registered, so the pet serves its own API
6
- * and media — the same pattern as dsh-remote-web-ui's `/api/pair` family.
3
+ * same-origin JSON endpoints ('/api/pet/*') and loads pet assets from
4
+ * '/pet/<id>/*'. The '/plugins/' endpoint only serves client bundles and RPC
5
+ * domains are platform-registered, so the pet serves its own API and media —
6
+ * the same pattern as dsh-remote-web-ui's '/api/pair' family. The asset route
7
+ * is one prefix registration serving every registry entry (manifest, atlas,
8
+ * optional previews), so adding a pet never touches route wiring.
7
9
  * @module @linxin666/dsh-pet/routes
8
10
  */
9
11
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
10
12
  import type { PetService } from './service.ts';
11
13
  /** Browser-facing base path of the pet API. */
12
14
  export declare const PET_API_PREFIX = "/api/pet";
13
- /** Browser-facing base path of the pet asset routes. */
14
- export declare const PET_ASSET_PREFIX = "/pet/whale";
15
- /** Absolute package root, resolved from this module's own location (lib/). */
16
- export declare function petPackageRoot(importMetaUrl: string): string;
17
- /** Build the full route family (API + assets) for one service + package root. */
15
+ /** Browser-facing base path of the pet asset routes ('/pet/<id>/...'). */
16
+ export declare const PET_ASSET_PREFIX = "/pet";
17
+ /** Build the full route family (API + assets) for one service. */
18
18
  export declare function makePetRoutes(deps: {
19
19
  service: PetService;
20
- packageRoot: string;
21
20
  }): WebRoute[];
21
+ export { petPackageRoot } from './registry.ts';
22
22
  //# sourceMappingURL=routes.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAG9C,+CAA+C;AAC/C,eAAO,MAAM,cAAc,aAAa,CAAA;AAExC,wDAAwD;AACxD,eAAO,MAAM,gBAAgB,eAAe,CAAA;AAQ5C,8EAA8E;AAC9E,wBAAgB,cAAc,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAE5D;AAkFD,iFAAiF;AACjF,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAAE,OAAO,EAAE,UAAU,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAAG,QAAQ,EAAE,CAuD5F"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAMH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAI9C,+CAA+C;AAC/C,eAAO,MAAM,cAAc,aAAa,CAAA;AAExC,0EAA0E;AAC1E,eAAO,MAAM,gBAAgB,SAAS,CAAA;AAuNtC,kEAAkE;AAClE,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAAE,OAAO,EAAE,UAAU,CAAA;CAAE,GAAG,QAAQ,EAAE,CAwCvE;AAGD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA"}
@@ -1,26 +1,38 @@
1
1
  /**
2
2
  * Pet HTTP routes — the browser half talks to the host through plain
3
- * same-origin JSON endpoints (`/api/pet/*`) and loads the whale-girl atlas
4
- * from `/pet/whale/*`. The `/plugins/` endpoint only serves client bundles
5
- * and RPC domains are platform-registered, so the pet serves its own API
6
- * and media — the same pattern as dsh-remote-web-ui's `/api/pair` family.
3
+ * same-origin JSON endpoints ('/api/pet/*') and loads pet assets from
4
+ * '/pet/<id>/*'. The '/plugins/' endpoint only serves client bundles and RPC
5
+ * domains are platform-registered, so the pet serves its own API and media —
6
+ * the same pattern as dsh-remote-web-ui's '/api/pair' family. The asset route
7
+ * is one prefix registration serving every registry entry (manifest, atlas,
8
+ * optional previews), so adding a pet never touches route wiring.
7
9
  * @module @linxin666/dsh-pet/routes
8
10
  */
11
+ import { existsSync } from 'node:fs';
9
12
  import { readFile } from 'node:fs/promises';
10
13
  import { join } from 'node:path';
11
- import { fileURLToPath } from 'node:url';
14
+ import { petEntryView } from "./registry.js";
12
15
  /** Browser-facing base path of the pet API. */
13
16
  export const PET_API_PREFIX = '/api/pet';
14
- /** Browser-facing base path of the pet asset routes. */
15
- export const PET_ASSET_PREFIX = '/pet/whale';
16
- /** Relative (to package root) asset files exposed under the prefix. */
17
- const ASSET_FILES = [
18
- { name: 'spritesheet.webp', mime: 'image/webp' },
19
- { name: 'pet.json', mime: 'application/json' },
20
- ];
21
- /** Absolute package root, resolved from this module's own location (lib/). */
22
- export function petPackageRoot(importMetaUrl) {
23
- return fileURLToPath(new URL('../', importMetaUrl));
17
+ /** Browser-facing base path of the pet asset routes ('/pet/<id>/...'). */
18
+ export const PET_ASSET_PREFIX = '/pet';
19
+ const MANIFEST_FILE = 'pet.json';
20
+ const PREVIEW_DIR = 'previews';
21
+ const PREVIEW_PATTERN = /^[A-Za-z0-9._-]+$/;
22
+ const MIME_BY_EXT = {
23
+ '.webp': 'image/webp',
24
+ '.png': 'image/png',
25
+ '.gif': 'image/gif',
26
+ '.jpg': 'image/jpeg',
27
+ '.jpeg': 'image/jpeg',
28
+ '.json': 'application/json',
29
+ };
30
+ /** Content type by file extension (safe fallback: octet-stream). */
31
+ function mimeFor(file) {
32
+ const dot = file.lastIndexOf('.');
33
+ if (dot < 0)
34
+ return 'application/octet-stream';
35
+ return MIME_BY_EXT[file.slice(dot).toLowerCase()] ?? 'application/octet-stream';
24
36
  }
25
37
  /** Write one JSON response. */
26
38
  function json(res, status, body) {
@@ -42,8 +54,6 @@ function readJsonBody(req) {
42
54
  req.on('data', (chunk) => {
43
55
  size += chunk.length;
44
56
  if (size > 64 * 1024) {
45
- // Reject first so the error handler can write the 400 response,
46
- // then close the connection once the response is flushed.
47
57
  reject(new Error('body-too-large'));
48
58
  queueMicrotask(() => req.destroy());
49
59
  return;
@@ -98,61 +108,169 @@ function postRoute(path, run) {
98
108
  },
99
109
  };
100
110
  }
101
- /** Build the full route family (API + assets) for one service + package root. */
111
+ /** Legacy URL aliases: each entry's directory basename (e.g. 'whale'). */
112
+ function dirAliases(registry) {
113
+ const aliases = new Map();
114
+ for (const entry of registry.entries) {
115
+ const alias = entry.dir.split(/[\\/]/).pop() ?? '';
116
+ if (alias !== '' && !aliases.has(alias))
117
+ aliases.set(alias, entry);
118
+ }
119
+ return aliases;
120
+ }
121
+ /**
122
+ * The one asset handler behind the '/pet' prefix. Resolves the pet by id (or
123
+ * legacy directory alias), then serves exactly the files a manifest declares:
124
+ * pet.json, the declared spritesheet path, and optional 'previews/<name>'
125
+ * media. Composed pets without a manifest file get a synthesized pet.json.
126
+ */
127
+ function assetHandler(registry) {
128
+ const aliases = dirAliases(registry);
129
+ return (req, res) => {
130
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
131
+ res.writeHead(405);
132
+ res.end();
133
+ return;
134
+ }
135
+ let pathname;
136
+ try {
137
+ pathname = new URL(req.url ?? '/', 'http://pet.local').pathname;
138
+ }
139
+ catch {
140
+ res.writeHead(400);
141
+ res.end();
142
+ return;
143
+ }
144
+ const segments = pathname.split('/').filter(segment => segment !== '');
145
+ if (segments[0] !== 'pet' || segments[1] === undefined) {
146
+ res.writeHead(404);
147
+ res.end();
148
+ return;
149
+ }
150
+ let id;
151
+ try {
152
+ id = decodeURIComponent(segments[1]);
153
+ }
154
+ catch {
155
+ res.writeHead(400);
156
+ res.end();
157
+ return;
158
+ }
159
+ const entry = registry.byId(id) ?? aliases.get(id);
160
+ if (entry === undefined) {
161
+ res.writeHead(404);
162
+ res.end();
163
+ return;
164
+ }
165
+ const rest = [];
166
+ for (const segment of segments.slice(2)) {
167
+ let decoded;
168
+ try {
169
+ decoded = decodeURIComponent(segment);
170
+ }
171
+ catch {
172
+ res.writeHead(400);
173
+ res.end();
174
+ return;
175
+ }
176
+ rest.push(decoded);
177
+ }
178
+ const rel = rest.join('/');
179
+ let file;
180
+ let synthesized = false;
181
+ if (rest.length === 1 && rest[0] === MANIFEST_FILE) {
182
+ const manifestFile = join(entry.dir, MANIFEST_FILE);
183
+ file = existsSync(manifestFile) ? manifestFile : undefined;
184
+ if (file === undefined)
185
+ synthesized = true;
186
+ }
187
+ else if (rest.length > 0 && rel === entry.spritesheetPath) {
188
+ file = join(entry.dir, entry.spritesheetPath);
189
+ }
190
+ else if (rest.length === 2 && rest[0] === PREVIEW_DIR && PREVIEW_PATTERN.test(rest[1])) {
191
+ const preview = join(entry.dir, PREVIEW_DIR, rest[1]);
192
+ file = existsSync(preview) ? preview : undefined;
193
+ }
194
+ if (synthesized) {
195
+ const body = Buffer.from(JSON.stringify(petEntryView(entry), null, 2), 'utf8');
196
+ res.writeHead(200, {
197
+ 'content-type': 'application/json; charset=utf-8',
198
+ 'content-length': String(body.byteLength),
199
+ 'cache-control': 'no-cache',
200
+ });
201
+ if (req.method === 'HEAD') {
202
+ res.end();
203
+ return;
204
+ }
205
+ res.end(body);
206
+ return;
207
+ }
208
+ if (file === undefined) {
209
+ res.writeHead(404);
210
+ res.end();
211
+ return;
212
+ }
213
+ const resolved = file;
214
+ readFile(resolved).then((body) => {
215
+ res.writeHead(200, {
216
+ 'content-type': mimeFor(resolved),
217
+ 'content-length': String(body.byteLength),
218
+ 'cache-control': 'no-cache',
219
+ });
220
+ if (req.method === 'HEAD') {
221
+ res.end();
222
+ return;
223
+ }
224
+ res.end(body);
225
+ }, () => {
226
+ res.writeHead(404);
227
+ res.end();
228
+ });
229
+ };
230
+ }
231
+ /** Build the full route family (API + assets) for one service. */
102
232
  export function makePetRoutes(deps) {
103
- const { service, packageRoot } = deps;
233
+ const { service } = deps;
104
234
  const apiRoutes = [
105
- getRoute(`${PET_API_PREFIX}/state`, () => service.state()),
106
- postRoute(`${PET_API_PREFIX}/interact`, (body) => {
235
+ getRoute(PET_API_PREFIX + '/state', () => service.state()),
236
+ getRoute(PET_API_PREFIX + '/pets', () => service.pets()),
237
+ postRoute(PET_API_PREFIX + '/interact', (body) => {
107
238
  const kind = body.kind;
108
239
  if (kind !== 'pet' && kind !== 'feed')
109
240
  return Promise.reject(new Error('invalid-kind'));
110
241
  return service.interact(kind);
111
242
  }),
112
- postRoute(`${PET_API_PREFIX}/set-visible`, (body) => {
243
+ postRoute(PET_API_PREFIX + '/set-visible', (body) => {
113
244
  const visible = body.visible;
114
245
  if (typeof visible !== 'boolean')
115
246
  return Promise.reject(new Error('invalid-visible'));
116
247
  return service.setVisible(visible);
117
248
  }),
118
- postRoute(`${PET_API_PREFIX}/set-config`, (body) => service.setConfig({
249
+ postRoute(PET_API_PREFIX + '/set-config', (body) => service.setConfig({
119
250
  ...(typeof body.size === 'number' ? { size: body.size } : {}),
120
251
  ...(typeof body.right === 'number' ? { right: body.right } : {}),
121
252
  ...(typeof body.bottom === 'number' ? { bottom: body.bottom } : {}),
122
253
  ...(typeof body.visible === 'boolean' ? { visible: body.visible } : {}),
123
254
  })),
124
- postRoute(`${PET_API_PREFIX}/set-name`, (body) => {
255
+ postRoute(PET_API_PREFIX + '/set-name', (body) => {
125
256
  const name = body.name;
126
257
  if (typeof name !== 'string')
127
258
  return Promise.reject(new Error('invalid-name'));
128
259
  return service.setName(name);
129
260
  }),
261
+ postRoute(PET_API_PREFIX + '/set-pet', (body) => {
262
+ const petId = body.petId;
263
+ if (typeof petId !== 'string')
264
+ return Promise.reject(new Error('invalid-pet'));
265
+ return service.setPetId(petId);
266
+ }),
130
267
  ];
131
- const assetRoutes = ASSET_FILES.map((file) => ({
132
- kind: 'exact',
133
- path: `${PET_ASSET_PREFIX}/${file.name}`,
134
- handler: (req, res) => {
135
- if (req.method !== 'GET' && req.method !== 'HEAD') {
136
- res.writeHead(405);
137
- res.end();
138
- return;
139
- }
140
- return readFile(join(packageRoot, 'assets', 'whale', file.name)).then((body) => {
141
- res.writeHead(200, {
142
- 'content-type': file.mime,
143
- 'content-length': String(body.byteLength),
144
- 'cache-control': 'no-cache',
145
- });
146
- if (req.method === 'HEAD') {
147
- res.end();
148
- return;
149
- }
150
- res.end(body);
151
- }, () => {
152
- res.writeHead(404);
153
- res.end();
154
- });
155
- },
156
- }));
157
- return [...apiRoutes, ...assetRoutes];
268
+ const assetRoute = {
269
+ kind: 'prefix',
270
+ path: PET_ASSET_PREFIX,
271
+ handler: assetHandler(service.registrySnapshot()),
272
+ };
273
+ return [...apiRoutes, assetRoute];
158
274
  }
275
+ // Re-exported for the package surface (the registry owns the definition now).
276
+ export { petPackageRoot } from "./registry.js";