miaoda-game-devkit 0.7.2 → 0.8.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.
@@ -0,0 +1,262 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, relative } from 'node:path';
5
+ import { x as extractTarball } from 'tar';
6
+
7
+ const MECHANIC_PREFIX = 'miaoda-game-';
8
+ const INDEX_SCHEMA_VERSION = 1;
9
+ const MAX_INDEX_BYTES = 5 * 1024 * 1024;
10
+ const MAX_TARBALL_BYTES = 64 * 1024 * 1024;
11
+
12
+ function fail(field, expected, repair) {
13
+ throw new Error(`miaoda mechanics source index: ${field} must ${expected}. ${repair}`);
14
+ }
15
+
16
+ function isMechanicName(value) {
17
+ return typeof value === 'string' && /^miaoda-game-[a-z0-9][a-z0-9._-]*$/.test(value);
18
+ }
19
+
20
+ function parsePackageSpec(spec) {
21
+ if (typeof spec !== 'string') return undefined;
22
+ const match = /^(miaoda-game-[a-z0-9][a-z0-9._-]*?)(?:@([^/]+))?$/.exec(spec);
23
+ if (!match) return undefined;
24
+ return { name: match[1], version: match[2] };
25
+ }
26
+
27
+ export function areIndexedPackageSpecs(specs) {
28
+ return Array.isArray(specs) && specs.length > 0 && specs.every((spec) => parsePackageSpec(spec));
29
+ }
30
+
31
+ async function readResponse(response, limit, label) {
32
+ if (!response.ok) {
33
+ fail(label, `download successfully, but received HTTP ${response.status}`, 'Check the public source URL and object permissions.');
34
+ }
35
+ const declaredLength = Number(response.headers.get('content-length'));
36
+ if (Number.isFinite(declaredLength) && declaredLength > limit) {
37
+ fail(label, `be at most ${limit} bytes`, 'Publish a smaller source artifact.');
38
+ }
39
+ if (!response.body) fail(label, 'contain a response body', 'Upload the source object again.');
40
+ const chunks = [];
41
+ let total = 0;
42
+ for await (const chunk of response.body) {
43
+ total += chunk.byteLength;
44
+ if (total > limit) {
45
+ fail(label, `be at most ${limit} bytes`, 'Publish a smaller source artifact.');
46
+ }
47
+ chunks.push(Buffer.from(chunk));
48
+ }
49
+ return Buffer.concat(chunks, total);
50
+ }
51
+
52
+ async function download(url, limit, label) {
53
+ let response;
54
+ try {
55
+ response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(30_000) });
56
+ } catch (error) {
57
+ fail(label, `be reachable at ${url}`, error instanceof Error ? error.message : String(error));
58
+ }
59
+ return readResponse(response, limit, label);
60
+ }
61
+
62
+ function validateVersionRecord(name, version, record, indexUrl) {
63
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
64
+ fail(`${name}@${version}`, 'be an object in the source index', 'Regenerate the source index.');
65
+ }
66
+ if (typeof record.url !== 'string' || record.url.length === 0) {
67
+ fail(`${name}@${version} url`, 'be a non-empty URL', 'Regenerate the source index.');
68
+ }
69
+ let url;
70
+ try {
71
+ url = new URL(record.url, indexUrl).href;
72
+ } catch {
73
+ fail(`${name}@${version} url`, 'be an absolute or index-relative HTTP(S) URL', 'Correct the source object URL.');
74
+ }
75
+ if (!/^https?:/.test(url)) {
76
+ fail(`${name}@${version} url`, 'use HTTP or HTTPS', 'Upload the source archive to the configured public storage.');
77
+ }
78
+ if (typeof record.sha256 !== 'string' || !/^[a-f0-9]{64}$/i.test(record.sha256)) {
79
+ fail(`${name}@${version} sha256`, 'contain 64 hexadecimal characters', 'Regenerate the source index after packing.');
80
+ }
81
+ const dependencies = record.dependencies ?? {};
82
+ if (!dependencies || typeof dependencies !== 'object' || Array.isArray(dependencies)) {
83
+ fail(`${name}@${version} dependencies`, 'be an object', 'Regenerate the source index.');
84
+ }
85
+ for (const [dependencyName, dependencyVersion] of Object.entries(dependencies)) {
86
+ if (!isMechanicName(dependencyName) || typeof dependencyVersion !== 'string' || dependencyVersion.length === 0) {
87
+ fail(
88
+ `${name}@${version} dependency ${dependencyName}`,
89
+ 'map a miaoda-game-* name to an exact non-empty version',
90
+ 'Regenerate the source index from workspace manifests.',
91
+ );
92
+ }
93
+ }
94
+ return { url, sha256: record.sha256.toLowerCase(), dependencies };
95
+ }
96
+
97
+ function selectVersion(index, name, requestedVersion, indexUrl) {
98
+ const packageRecord = index.packages[name];
99
+ if (!packageRecord) {
100
+ fail(name, 'exist in the source index', 'Upload the package and regenerate stable.json.');
101
+ }
102
+ const version = requestedVersion || packageRecord.latest;
103
+ if (typeof version !== 'string' || version.length === 0) {
104
+ fail(`${name} latest`, 'be a non-empty version', 'Regenerate stable.json with a latest pointer.');
105
+ }
106
+ const versionRecord = packageRecord.versions?.[version];
107
+ if (!versionRecord) {
108
+ fail(`${name}@${version}`, 'exist in the source index', 'Upload that version or request an indexed version.');
109
+ }
110
+ return { name, version, ...validateVersionRecord(name, version, versionRecord, indexUrl) };
111
+ }
112
+
113
+ function resolveGraph(index, indexUrl, previousRoots, specs) {
114
+ const roots = new Map();
115
+ for (const [name, version] of Object.entries(previousRoots ?? {})) {
116
+ if (isMechanicName(name) && typeof version === 'string' && version.length > 0) roots.set(name, version);
117
+ }
118
+ for (const spec of specs) {
119
+ const parsed = parsePackageSpec(spec);
120
+ if (!parsed) fail(`package spec ${spec}`, 'be a miaoda-game-* name with an optional @version', 'Pass TGZ URLs without --source-index.');
121
+ const selected = selectVersion(index, parsed.name, parsed.version, indexUrl);
122
+ roots.set(parsed.name, selected.version);
123
+ }
124
+
125
+ const selectedPackages = new Map();
126
+ const queue = [...roots].map(([name, version]) => ({ name, version, requestedBy: 'root selection' }));
127
+ while (queue.length > 0) {
128
+ const request = queue.shift();
129
+ const existing = selectedPackages.get(request.name);
130
+ if (existing) {
131
+ if (existing.version !== request.version) {
132
+ fail(
133
+ `resolved versions for ${request.name}`,
134
+ `contain one version, but found ${existing.version} and ${request.version}`,
135
+ `Align ${request.requestedBy} with the existing source dependency graph.`,
136
+ );
137
+ }
138
+ continue;
139
+ }
140
+ const selected = selectVersion(index, request.name, request.version, indexUrl);
141
+ selectedPackages.set(request.name, selected);
142
+ for (const [dependencyName, dependencyVersion] of Object.entries(selected.dependencies).sort()) {
143
+ queue.push({ name: dependencyName, version: dependencyVersion, requestedBy: `${selected.name}@${selected.version}` });
144
+ }
145
+ }
146
+ return { roots: Object.fromEntries([...roots].sort()), selectedPackages };
147
+ }
148
+
149
+ async function readIndex(indexUrl) {
150
+ let normalizedUrl;
151
+ try {
152
+ normalizedUrl = new URL(indexUrl).href;
153
+ } catch {
154
+ fail('index URL', 'be an absolute HTTP(S) URL', 'Pass --source-index=https://.../stable.json.');
155
+ }
156
+ if (!/^https?:/.test(normalizedUrl)) {
157
+ fail('index URL', 'use HTTP or HTTPS', 'Host stable.json on publicly readable storage.');
158
+ }
159
+ const bytes = await download(normalizedUrl, MAX_INDEX_BYTES, 'stable.json');
160
+ let index;
161
+ try {
162
+ index = JSON.parse(new TextDecoder().decode(bytes));
163
+ } catch (error) {
164
+ fail('stable.json', 'contain valid JSON', error instanceof Error ? error.message : String(error));
165
+ }
166
+ if (index?.schemaVersion !== INDEX_SCHEMA_VERSION || !index.packages || typeof index.packages !== 'object') {
167
+ fail('stable.json', `use schemaVersion ${INDEX_SCHEMA_VERSION} and a packages object`, 'Regenerate the source index.');
168
+ }
169
+ return { index, indexUrl: normalizedUrl };
170
+ }
171
+
172
+ function assertExtractedTree(root, name) {
173
+ const stack = [root];
174
+ while (stack.length > 0) {
175
+ const current = stack.pop();
176
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
177
+ if (entry.isSymbolicLink()) {
178
+ fail(`${name} ${relative(root, join(current, entry.name))}`, 'not be a symbolic link', 'Repack the source archive without links.');
179
+ }
180
+ if (entry.isDirectory()) stack.push(join(current, entry.name));
181
+ }
182
+ }
183
+ }
184
+
185
+ async function extractPackage(targetRoot, selected, bytes) {
186
+ const actualHash = createHash('sha256').update(bytes).digest('hex');
187
+ if (actualHash !== selected.sha256) {
188
+ fail(
189
+ `${selected.name}@${selected.version} sha256`,
190
+ `match stable.json (${selected.sha256}), but downloaded ${actualHash}`,
191
+ 'Upload the correct immutable TGZ or regenerate stable.json.',
192
+ );
193
+ }
194
+ const tarballPath = join(targetRoot, `${selected.name}-${selected.version}.tgz`);
195
+ const packageDirectory = join(targetRoot, selected.name);
196
+ writeFileSync(tarballPath, bytes);
197
+ mkdirSync(packageDirectory, { recursive: true });
198
+ await extractTarball({
199
+ cwd: packageDirectory,
200
+ file: tarballPath,
201
+ strip: 1,
202
+ strict: true,
203
+ preservePaths: false,
204
+ filter(path, entry) {
205
+ if (!path.startsWith('package/')) return false;
206
+ if (entry.type === 'SymbolicLink' || entry.type === 'Link') {
207
+ fail(`${selected.name}@${selected.version} archive`, 'not contain links', 'Repack the source archive with regular files only.');
208
+ }
209
+ return true;
210
+ },
211
+ });
212
+ assertExtractedTree(packageDirectory, selected.name);
213
+ const manifestPath = join(packageDirectory, 'package.json');
214
+ if (!existsSync(manifestPath)) fail(`${selected.name} package.json`, 'exist in the TGZ', 'Repack the source package.');
215
+ let manifest;
216
+ try {
217
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
218
+ } catch (error) {
219
+ fail(
220
+ `${selected.name}@${selected.version} package.json`,
221
+ 'contain valid JSON',
222
+ error instanceof Error ? error.message : String(error),
223
+ );
224
+ }
225
+ if (manifest.name !== selected.name || manifest.version !== selected.version) {
226
+ fail(
227
+ `${selected.name}@${selected.version} manifest identity`,
228
+ `match the index, but found ${manifest.name}@${manifest.version}`,
229
+ 'Regenerate stable.json from the exact uploaded TGZ.',
230
+ );
231
+ }
232
+ if (!existsSync(join(packageDirectory, 'src'))) {
233
+ fail(`${selected.name}@${selected.version} src`, 'exist in the TGZ', 'Pack production TypeScript source before uploading.');
234
+ }
235
+ return { directory: packageDirectory, manifest, version: selected.version, sourceUrl: selected.url, artifactSha256: actualHash };
236
+ }
237
+
238
+ export async function resolveIndexedMechanics({ indexUrl, previousRoots = {}, specs }) {
239
+ const loaded = await readIndex(indexUrl);
240
+ const graph = resolveGraph(loaded.index, loaded.indexUrl, previousRoots, specs);
241
+ const temporaryRoot = mkdtempSync(join(tmpdir(), 'miaoda-mechanics-source-index-'));
242
+ const resolvedPackages = new Map();
243
+ try {
244
+ for (const selected of [...graph.selectedPackages.values()].sort((left, right) => left.name.localeCompare(right.name))) {
245
+ const bytes = await download(selected.url, MAX_TARBALL_BYTES, `${selected.name}@${selected.version}`);
246
+ resolvedPackages.set(selected.name, await extractPackage(temporaryRoot, selected, bytes));
247
+ }
248
+ return {
249
+ roots: graph.roots,
250
+ resolvedPackages,
251
+ temporaryRoot,
252
+ sourceIndexUrl: loaded.indexUrl,
253
+ };
254
+ } catch (error) {
255
+ rmSync(temporaryRoot, { recursive: true, force: true });
256
+ throw error;
257
+ }
258
+ }
259
+
260
+ export function cleanupIndexedMechanics(resolution) {
261
+ if (resolution?.temporaryRoot) rmSync(resolution.temporaryRoot, { recursive: true, force: true });
262
+ }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "miaoda-game-devkit",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Shared React and Phaser game lint plus deterministic testing tools for Miaoda games",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.mjs",
8
8
  "types": "./dist/index.d.ts",
9
9
  "bin": {
10
+ "miaoda": "bin/miaoda.js",
10
11
  "miaoda-phaser-game-lint": "bin/miaoda-phaser-game-lint.js",
11
12
  "miaoda-react-game-lint": "bin/miaoda-react-game-lint.js"
12
13
  },
@@ -73,7 +74,6 @@
73
74
  }
74
75
  },
75
76
  "./react/vitest-setup": "./dist/react/vitest-setup.mjs",
76
- "./pnpmfile": "./mechanics/pnpmfile.cjs",
77
77
  "./biome": "./biome-config.json",
78
78
  "./tsconfig-base": "./tsconfig-base.json"
79
79
  },
@@ -87,7 +87,8 @@
87
87
  "!dist/**/*.map",
88
88
  "!dist/lint/*.contract.mjs",
89
89
  "!dist/lint/contracts.config.mjs",
90
- "!dist/lint/**/*.test.*"
90
+ "!dist/lint/**/*.test.*",
91
+ "!mechanics/*.test.mjs"
91
92
  ],
92
93
  "nx": {
93
94
  "tags": [
@@ -110,7 +111,7 @@
110
111
  "build"
111
112
  ],
112
113
  "options": {
113
- "command": "pnpm run test:contracts && pnpm run test:published",
114
+ "command": "pnpm run test:contracts && pnpm run test:mechanics && pnpm run test:published",
114
115
  "cwd": "packages/game-devkit"
115
116
  }
116
117
  }
@@ -120,10 +121,11 @@
120
121
  "@biomejs/biome": "2.5.6",
121
122
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
122
123
  "jsdom": "29.1.1",
123
- "oxc-resolver": "11.24.2",
124
124
  "oxc-parser": "0.144.0",
125
+ "oxc-resolver": "11.24.2",
125
126
  "oxlint": "1.76.0",
126
127
  "tailwindcss": "3.4.19",
128
+ "tar": "7.5.22",
127
129
  "vitest": "4.1.10"
128
130
  },
129
131
  "peerDependencies": {
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/tsconfig",
3
3
  "compilerOptions": {
4
4
  "target": "ES2020",
5
- "useDefineForClassFields": true,
5
+ "useDefineForClassFields": false,
6
6
  "lib": ["ES2020", "DOM", "DOM.Iterable"],
7
7
  "module": "ESNext",
8
8
  "moduleResolution": "bundler",