animader-cli 0.0.0-stage → 0.1.1
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/LICENSE +121 -0
- package/adapters/build-node.mjs +265 -0
- package/adapters/devserver.mjs +481 -0
- package/adapters/devsession.mjs +619 -0
- package/adapters/export-node.mjs +603 -0
- package/adapters/export-run.mjs +126 -0
- package/adapters/generations.mjs +183 -0
- package/adapters/http.mjs +294 -0
- package/adapters/launch.mjs +83 -0
- package/adapters/node-fs.mjs +80 -0
- package/adapters/pngine.mjs +87 -0
- package/adapters/snapshot.mjs +143 -0
- package/adapters/target.mjs +104 -0
- package/adapters/watch.mjs +355 -0
- package/adapters/webaudio.mjs +102 -0
- package/bin/animader.mjs +31 -0
- package/hello/join.js +46 -0
- package/hello/run.js +259 -0
- package/lib/build.mjs +126 -0
- package/lib/buildloop.mjs +254 -0
- package/lib/contract.mjs +371 -0
- package/lib/errors.mjs +51 -0
- package/lib/export.mjs +445 -0
- package/lib/new.mjs +304 -0
- package/lib/pipeline.mjs +247 -0
- package/lib/receipt.mjs +331 -0
- package/lib/rmtree.mjs +49 -0
- package/lib/routes.mjs +170 -0
- package/lib/score.mjs +434 -0
- package/lib/show.mjs +458 -0
- package/lib/soundtrack.mjs +67 -0
- package/lib/vendor.mjs +99 -0
- package/lib/wav.mjs +197 -0
- package/package.json +47 -4
- package/player/boot.mjs +792 -0
- package/player/controller.mjs +366 -0
- package/player/devchannel.mjs +306 -0
- package/player/index.html +35 -0
- package/player/page.mjs +134 -0
- package/player/player.css +393 -0
- package/player/release.mjs +76 -0
- package/src/commands/build.mjs +201 -0
- package/src/commands/dev.mjs +214 -0
- package/src/commands/doctor.mjs +348 -0
- package/src/commands/export.mjs +158 -0
- package/src/commands/new.mjs +164 -0
- package/src/commands/project.mjs +46 -0
- package/src/config.mjs +110 -0
- package/src/router.mjs +149 -0
- package/src/toolchain.mjs +691 -0
- package/starters/circle/README.md +50 -0
- package/starters/circle/animader.json +4 -0
- package/starters/circle/gitignore +9 -0
- package/starters/circle/kernels/circle.wgsl +32 -0
- package/starters/circle/program.sjon +34 -0
- package/starters/circle/timeline.piece.sjon +26 -0
- package/starters/ember/bake/spans.bin +0 -0
- package/starters/ember/kernels/curl.wgsl +10 -0
- package/starters/ember/kernels/ember.wgsl +10 -0
- package/starters/ember/kernels/hash.wgsl +12 -0
- package/starters/ember/kernels/simplex.wgsl +27 -0
- package/starters/ember/program.sjon +328 -0
- package/starters/ember/timeline.piece.sjon +34 -0
- package/toolchain/licenses/animader.txt +121 -0
- package/toolchain/licenses/esbuild.txt +21 -0
- package/toolchain/licenses/pacer.txt +121 -0
- package/toolchain/licenses/pngine.txt +121 -0
- package/toolchain/manifest.json +212 -0
- package/toolchain/prepare.mjs +446 -0
- package/toolchain/uniform-type-name.js +184 -0
- package/README.md +0 -3
package/lib/receipt.mjs
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// The build receipt is the minimal data artifact a later UI or export can
|
|
2
|
+
// read without calling the CLI: what inputs were hashed, what payload was
|
|
3
|
+
// produced, which prepared runtime was verified, and which native producer
|
|
4
|
+
// baked it. It names relative output paths only and carries no timestamps.
|
|
5
|
+
//
|
|
6
|
+
// Ownership: receipt shape and validation. Writing it is host IO.
|
|
7
|
+
|
|
8
|
+
import { PROJECT_FILES } from './contract.mjs';
|
|
9
|
+
import { DemoError } from './errors.mjs';
|
|
10
|
+
import { isSoundtrackName, soundtrackNamesText } from './soundtrack.mjs';
|
|
11
|
+
|
|
12
|
+
export const RECEIPT_FORMAT = 'animader-demo-build/1';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The full-build receipt format: the graphics-only receipt plus the packed
|
|
16
|
+
* score artifact, the toolchain that produced both, and the consumed input
|
|
17
|
+
* coverage. A `/1` receipt still parses through the `/1` path above, but
|
|
18
|
+
* only this format records a validated score — never treat a graphics-only
|
|
19
|
+
* receipt as proof the score compiled or plays.
|
|
20
|
+
*/
|
|
21
|
+
export const SCORE_RECEIPT_FORMAT = 'animader-demo-build/2';
|
|
22
|
+
|
|
23
|
+
/** The input-coverage methods a score-build receipt may record. */
|
|
24
|
+
export const SCORE_RECEIPT_INPUT_METHODS = Object.freeze(['input-snapshot', 'compiler-metadata']);
|
|
25
|
+
|
|
26
|
+
function bad(message) {
|
|
27
|
+
return new DemoError('bad_build_receipt', message, {
|
|
28
|
+
remedy: 'Rebuild with: node demo/show.mjs <folder> --seconds <n> --preflight-only',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function badScore(message) {
|
|
33
|
+
return new DemoError('bad_build_receipt', message, {
|
|
34
|
+
remedy: 'Rebuild with: animader build [folder] [--seconds S]',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cleanRelative(value, label, fail = bad) {
|
|
39
|
+
const text = String(value ?? '');
|
|
40
|
+
if (
|
|
41
|
+
text === '' || text.startsWith('/') || text.includes('\\')
|
|
42
|
+
|| text.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')
|
|
43
|
+
) {
|
|
44
|
+
throw fail(`${label} must be a clean relative path; got ${JSON.stringify(text)}`);
|
|
45
|
+
}
|
|
46
|
+
return text;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function digest(value, label, fail = bad) {
|
|
50
|
+
const text = String(value ?? '');
|
|
51
|
+
if (!/^[0-9a-f]{64}$/.test(text)) throw fail(`${label} must be a sha256 digest`);
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function bytes(value, label, fail = bad) {
|
|
56
|
+
if (!Number.isInteger(value) || value <= 0) throw fail(`${label} must be a positive byte length`);
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function fileRow(row, name, label, fail = bad) {
|
|
61
|
+
if (row === null || typeof row !== 'object') throw fail(`${label} is missing`);
|
|
62
|
+
// The soundtrack seat carries the actual selected name; the fixed seats
|
|
63
|
+
// keep their exact filenames. Old WAV records remain readable because the
|
|
64
|
+
// legacy name is still one of the recognized names.
|
|
65
|
+
const selected = label === 'project.files.soundtrack';
|
|
66
|
+
const valid = selected ? isSoundtrackName(row.name) : row.name === name;
|
|
67
|
+
if (!valid) {
|
|
68
|
+
const expected = selected ? `one of ${soundtrackNamesText()}` : name;
|
|
69
|
+
throw fail(`${label} must name ${expected}; got ${JSON.stringify(row.name)}`);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
name: selected ? row.name : name,
|
|
73
|
+
bytes: bytes(row.bytes, label, fail),
|
|
74
|
+
sha256: digest(row.sha256, label, fail),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function projectName(project, explicit) {
|
|
79
|
+
if (explicit !== undefined && explicit !== '') return String(explicit);
|
|
80
|
+
const folder = String(project?.folder ?? '');
|
|
81
|
+
const parts = folder.split(/[\\/]/).filter(Boolean);
|
|
82
|
+
return parts.at(-1) ?? 'untitled';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build the receipt object. All fields are data; no folder is carried.
|
|
87
|
+
*
|
|
88
|
+
* @param {Object} options
|
|
89
|
+
* @param {{folder?: string, files: Object}} options.project
|
|
90
|
+
* @param {string} [options.projectName]
|
|
91
|
+
* @param {number} options.seconds
|
|
92
|
+
* @param {{name: string, bytes: number, sha256: string}} options.output
|
|
93
|
+
* @param {{format: string, digest: string, files: number}} options.runtime
|
|
94
|
+
* @param {{name: string, sha256: string}} options.tool
|
|
95
|
+
*/
|
|
96
|
+
export function makeBuildReceipt({ project, projectName: explicitName, seconds, output, runtime, tool }) {
|
|
97
|
+
if (!Number.isFinite(seconds) || seconds <= 0) throw bad('show.seconds must be positive and finite');
|
|
98
|
+
if (project?.files === null || typeof project?.files !== 'object') throw bad('project.files is missing');
|
|
99
|
+
|
|
100
|
+
const files = {};
|
|
101
|
+
for (const [seat, name] of Object.entries(PROJECT_FILES)) {
|
|
102
|
+
files[seat] = fileRow(project.files[seat], name, `project.files.${seat}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (runtime === null || typeof runtime !== 'object') throw bad('runtime is missing');
|
|
106
|
+
if (runtime.format !== 'animader-demo-vendor/1') throw bad(`runtime.format is not the prepared bundle format; got ${JSON.stringify(runtime.format)}`);
|
|
107
|
+
if (!Number.isInteger(runtime.files) || runtime.files <= 0) throw bad('runtime.files must be a positive count');
|
|
108
|
+
if (tool === null || typeof tool !== 'object') throw bad('tool is missing');
|
|
109
|
+
const toolName = String(tool.name ?? '');
|
|
110
|
+
if (toolName === '') throw bad('tool.name is missing');
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
format: RECEIPT_FORMAT,
|
|
114
|
+
show: { seconds },
|
|
115
|
+
project: {
|
|
116
|
+
name: projectName(project, explicitName),
|
|
117
|
+
files,
|
|
118
|
+
},
|
|
119
|
+
output: {
|
|
120
|
+
name: cleanRelative(output?.name, 'output.name'),
|
|
121
|
+
bytes: bytes(output?.bytes, 'output.bytes'),
|
|
122
|
+
sha256: digest(output?.sha256, 'output.sha256'),
|
|
123
|
+
},
|
|
124
|
+
runtime: {
|
|
125
|
+
format: runtime.format,
|
|
126
|
+
digest: digest(runtime.digest, 'runtime.digest'),
|
|
127
|
+
files: runtime.files,
|
|
128
|
+
},
|
|
129
|
+
tool: {
|
|
130
|
+
name: toolName,
|
|
131
|
+
sha256: digest(tool.sha256, 'tool.sha256'),
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Validate a parsed receipt and return its normalized copy.
|
|
138
|
+
*
|
|
139
|
+
* @param {unknown} receipt
|
|
140
|
+
*/
|
|
141
|
+
export function validateReceipt(receipt) {
|
|
142
|
+
if (receipt === null || typeof receipt !== 'object' || receipt.format !== RECEIPT_FORMAT) {
|
|
143
|
+
throw bad(`receipt format must be ${RECEIPT_FORMAT}`);
|
|
144
|
+
}
|
|
145
|
+
return makeBuildReceipt({
|
|
146
|
+
project: receipt.project,
|
|
147
|
+
projectName: receipt.project?.name,
|
|
148
|
+
seconds: receipt.show?.seconds,
|
|
149
|
+
output: receipt.output,
|
|
150
|
+
runtime: receipt.runtime,
|
|
151
|
+
tool: receipt.tool,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function serializeReceipt(receipt) {
|
|
156
|
+
return `${JSON.stringify(validateReceipt(receipt), null, 2)}\n`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function parseReceiptText(text) {
|
|
160
|
+
let parsed;
|
|
161
|
+
try {
|
|
162
|
+
parsed = JSON.parse(String(text));
|
|
163
|
+
} catch (error) {
|
|
164
|
+
throw bad(`receipt is not JSON: ${error.message}`);
|
|
165
|
+
}
|
|
166
|
+
return validateReceipt(parsed);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function toolRow(row, label) {
|
|
170
|
+
if (row === null || typeof row !== 'object') throw badScore(`${label} is missing`);
|
|
171
|
+
const name = String(row.name ?? '');
|
|
172
|
+
if (name === '') throw badScore(`${label}.name is missing`);
|
|
173
|
+
return { name, sha256: digest(row.sha256, `${label}.sha256`, badScore) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function inputRow(row, label) {
|
|
177
|
+
if (row === null || typeof row !== 'object') throw badScore(`${label} is missing`);
|
|
178
|
+
return {
|
|
179
|
+
path: cleanRelative(row.path, `${label}.path`, badScore),
|
|
180
|
+
bytes: bytes(row.bytes, `${label}.bytes`, badScore),
|
|
181
|
+
sha256: digest(row.sha256, `${label}.sha256`, badScore),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function inputRows(rows) {
|
|
186
|
+
if (!Array.isArray(rows) || rows.length === 0) throw badScore('inputs.files must be a non-empty array of consumed input rows');
|
|
187
|
+
const normalized = rows.map((row, index) => inputRow(row, `inputs.files[${index}]`));
|
|
188
|
+
const paths = normalized.map((row) => row.path);
|
|
189
|
+
if (paths.some((path, index) => index > 0 && paths[index - 1] >= path)) {
|
|
190
|
+
throw badScore('inputs.files must be sorted by path with no duplicates');
|
|
191
|
+
}
|
|
192
|
+
return normalized;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function inputsBlock(inputs) {
|
|
196
|
+
if (inputs === null || typeof inputs !== 'object') throw badScore('inputs is missing');
|
|
197
|
+
if (!SCORE_RECEIPT_INPUT_METHODS.includes(inputs.method)) {
|
|
198
|
+
throw badScore(`inputs.method must be one of ${SCORE_RECEIPT_INPUT_METHODS.join(', ')}; got ${JSON.stringify(inputs.method ?? null)}`);
|
|
199
|
+
}
|
|
200
|
+
const note = String(inputs.note ?? '');
|
|
201
|
+
if (note === '') throw badScore('inputs.note must say which input-coverage method produced the rows');
|
|
202
|
+
return { method: inputs.method, note, files: inputRows(inputs.files) };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Build the score-build receipt: both artifacts, the resolved host
|
|
207
|
+
* settings, the producer toolchain and the consumed input coverage in one
|
|
208
|
+
* data artifact. All fields are data; no folder is carried.
|
|
209
|
+
*
|
|
210
|
+
* @param {Object} options
|
|
211
|
+
* @param {{folder?: string, files: Object}} options.project
|
|
212
|
+
* @param {string} [options.projectName]
|
|
213
|
+
* @param {number} options.seconds
|
|
214
|
+
* @param {{name: string, bytes: number, sha256: string}} options.outputs.payload
|
|
215
|
+
* @param {{name: string, bytes: number, sha256: string}} options.outputs.score
|
|
216
|
+
* @param {{method: string, note: string, files: Array<{path: string, bytes: number, sha256: string}>}} options.inputs
|
|
217
|
+
* @param {{format: string, digest: string, files: number}} options.runtime
|
|
218
|
+
* @param {{name: string, sha256: string}} options.toolchain.pngine
|
|
219
|
+
* @param {{name: string, sha256: string}} options.toolchain.pacer
|
|
220
|
+
*/
|
|
221
|
+
export function makeScoreBuildReceipt({
|
|
222
|
+
project,
|
|
223
|
+
projectName: explicitName,
|
|
224
|
+
seconds,
|
|
225
|
+
outputs,
|
|
226
|
+
inputs,
|
|
227
|
+
runtime,
|
|
228
|
+
toolchain,
|
|
229
|
+
}) {
|
|
230
|
+
if (!Number.isFinite(seconds) || seconds <= 0) throw badScore('show.seconds must be positive and finite');
|
|
231
|
+
if (project?.files === null || typeof project?.files !== 'object') throw badScore('project.files is missing');
|
|
232
|
+
|
|
233
|
+
const files = {};
|
|
234
|
+
for (const [seat, name] of Object.entries(PROJECT_FILES)) {
|
|
235
|
+
files[seat] = fileRow(project.files[seat], name, `project.files.${seat}`, badScore);
|
|
236
|
+
}
|
|
237
|
+
if (runtime === null || typeof runtime !== 'object') throw badScore('runtime is missing');
|
|
238
|
+
if (runtime.format !== 'animader-demo-vendor/1') throw badScore(`runtime.format is not the prepared bundle format; got ${JSON.stringify(runtime.format)}`);
|
|
239
|
+
if (!Number.isInteger(runtime.files) || runtime.files <= 0) throw badScore('runtime.files must be a positive count');
|
|
240
|
+
if (outputs === null || typeof outputs !== 'object') throw badScore('outputs is missing');
|
|
241
|
+
if (toolchain === null || typeof toolchain !== 'object') throw badScore('toolchain is missing');
|
|
242
|
+
|
|
243
|
+
const payload = outputs.payload;
|
|
244
|
+
if (payload === null || typeof payload !== 'object') throw badScore('outputs.payload is missing');
|
|
245
|
+
const score = outputs.score;
|
|
246
|
+
if (score === null || typeof score !== 'object') throw badScore('outputs.score is missing');
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
format: SCORE_RECEIPT_FORMAT,
|
|
250
|
+
show: { seconds },
|
|
251
|
+
project: {
|
|
252
|
+
name: projectName(project, explicitName),
|
|
253
|
+
files,
|
|
254
|
+
},
|
|
255
|
+
outputs: {
|
|
256
|
+
payload: {
|
|
257
|
+
name: cleanRelative(payload.name, 'outputs.payload.name', badScore),
|
|
258
|
+
bytes: bytes(payload.bytes, 'outputs.payload.bytes', badScore),
|
|
259
|
+
sha256: digest(payload.sha256, 'outputs.payload.sha256', badScore),
|
|
260
|
+
},
|
|
261
|
+
score: {
|
|
262
|
+
name: cleanRelative(score.name, 'outputs.score.name', badScore),
|
|
263
|
+
bytes: bytes(score.bytes, 'outputs.score.bytes', badScore),
|
|
264
|
+
sha256: digest(score.sha256, 'outputs.score.sha256', badScore),
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
inputs: inputsBlock(inputs),
|
|
268
|
+
runtime: {
|
|
269
|
+
format: runtime.format,
|
|
270
|
+
digest: digest(runtime.digest, 'runtime.digest', badScore),
|
|
271
|
+
files: runtime.files,
|
|
272
|
+
},
|
|
273
|
+
toolchain: {
|
|
274
|
+
pngine: toolRow(toolchain.pngine, 'toolchain.pngine'),
|
|
275
|
+
pacer: toolRow(toolchain.pacer, 'toolchain.pacer'),
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Validate a parsed score-build receipt and return its normalized copy.
|
|
282
|
+
* A graphics-only (`/1`) receipt is not this format and is refused here;
|
|
283
|
+
* read it with `validateReceipt` and never as proof of a validated score.
|
|
284
|
+
*
|
|
285
|
+
* @param {unknown} receipt
|
|
286
|
+
*/
|
|
287
|
+
export function validateScoreBuildReceipt(receipt) {
|
|
288
|
+
if (receipt === null || typeof receipt !== 'object' || receipt.format !== SCORE_RECEIPT_FORMAT) {
|
|
289
|
+
throw badScore(`score-build receipt format must be ${SCORE_RECEIPT_FORMAT}`);
|
|
290
|
+
}
|
|
291
|
+
return makeScoreBuildReceipt({
|
|
292
|
+
project: receipt.project,
|
|
293
|
+
projectName: receipt.project?.name,
|
|
294
|
+
seconds: receipt.show?.seconds,
|
|
295
|
+
outputs: receipt.outputs,
|
|
296
|
+
inputs: receipt.inputs,
|
|
297
|
+
runtime: receipt.runtime,
|
|
298
|
+
toolchain: receipt.toolchain,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function serializeScoreBuildReceipt(receipt) {
|
|
303
|
+
return `${JSON.stringify(validateScoreBuildReceipt(receipt), null, 2)}\n`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function parseScoreBuildReceiptText(text) {
|
|
307
|
+
let parsed;
|
|
308
|
+
try {
|
|
309
|
+
parsed = JSON.parse(String(text));
|
|
310
|
+
} catch (error) {
|
|
311
|
+
throw badScore(`score-build receipt is not JSON: ${error.message}`);
|
|
312
|
+
}
|
|
313
|
+
return validateScoreBuildReceipt(parsed);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Whether a parsed receipt proves a validated score. Only a well-formed
|
|
318
|
+
* score-build receipt (`animader-demo-build/2`) does; a graphics-only
|
|
319
|
+
* receipt parses but answers false, and anything malformed answers false.
|
|
320
|
+
*
|
|
321
|
+
* @param {unknown} receipt
|
|
322
|
+
* @returns {boolean}
|
|
323
|
+
*/
|
|
324
|
+
export function receiptValidatesScore(receipt) {
|
|
325
|
+
try {
|
|
326
|
+
validateScoreBuildReceipt(receipt);
|
|
327
|
+
return true;
|
|
328
|
+
} catch {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
}
|
package/lib/rmtree.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The one retrying recursive tree removal: the disposal guard. A recursive
|
|
2
|
+
// removal can answer a transient code when the directory is observed while
|
|
3
|
+
// it changes underneath (an entry landing between the walk and the rmdir, a
|
|
4
|
+
// tree swapped mid-walk); the fix for those is upstream — every tree has
|
|
5
|
+
// one owner, and writers are quiesced before disposal — so this retry is
|
|
6
|
+
// only the last guard, never the mechanism. The codes, the attempt count
|
|
7
|
+
// and the backoff are the contract; do not lengthen them here.
|
|
8
|
+
//
|
|
9
|
+
// This module is portable: no `node:` import, no process state, no DOM.
|
|
10
|
+
// The removal and the delay are injected ports; the Node binding every
|
|
11
|
+
// caller uses lives in adapters/node-fs.mjs.
|
|
12
|
+
|
|
13
|
+
/** The transient codes a recursive removal answers while it races an observer. */
|
|
14
|
+
export const REMOVAL_RETRY_CODES = Object.freeze(new Set(['ENOTEMPTY', 'EBUSY', 'ENOTDIR']));
|
|
15
|
+
/** A few short attempts: enough to clear a transient observer, never a leak. */
|
|
16
|
+
export const REMOVAL_ATTEMPTS = 4;
|
|
17
|
+
export const REMOVAL_BACKOFF_MS = 25;
|
|
18
|
+
|
|
19
|
+
const defaultDelay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Remove one tree, retrying the transient codes a short fixed number of
|
|
23
|
+
* times and rejecting with the last observed error when they persist.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} path the tree to remove
|
|
26
|
+
* @param {Object} ports
|
|
27
|
+
* @param {(path: string) => Promise<void>} ports.remove the recursive
|
|
28
|
+
* removal port (Node binds `fsp.rm(path, { recursive: true, force: true })`)
|
|
29
|
+
* @param {(ms: number) => Promise<void>} [ports.delay] the backoff port
|
|
30
|
+
*/
|
|
31
|
+
export async function removeTree(path, { remove, delay = defaultDelay }) {
|
|
32
|
+
if (typeof remove !== 'function') {
|
|
33
|
+
throw new TypeError('removeTree needs an injected remove(path) port');
|
|
34
|
+
}
|
|
35
|
+
let lastError = null;
|
|
36
|
+
for (let attempt = 1; attempt <= REMOVAL_ATTEMPTS; attempt += 1) {
|
|
37
|
+
try {
|
|
38
|
+
await remove(path);
|
|
39
|
+
return;
|
|
40
|
+
} catch (cause) {
|
|
41
|
+
if (!REMOVAL_RETRY_CODES.has(cause?.code)) throw cause;
|
|
42
|
+
lastError = cause;
|
|
43
|
+
if (attempt < REMOVAL_ATTEMPTS) {
|
|
44
|
+
await delay(REMOVAL_BACKOFF_MS * attempt);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
throw lastError;
|
|
49
|
+
}
|
package/lib/routes.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// HTTP route decisions, portable. A request is mapped to the actual player,
|
|
2
|
+
// the host config, the preflight health answer, or an asset under exactly one
|
|
3
|
+
// of the narrow mounts. The player surface, the portable core modules and the
|
|
4
|
+
// two hello files they import are allowlisted by name: a request never reaches
|
|
5
|
+
// the rest of the checkout. The path is decoded once, then split; traversal,
|
|
6
|
+
// encoded separators, NUL, backslashes and unknown MIME are refused rather
|
|
7
|
+
// than guessed. Filesystem canonicalization is the host adapter's job.
|
|
8
|
+
//
|
|
9
|
+
// Ownership: route grammar and MIME vocabulary. No node:, process or DOM.
|
|
10
|
+
|
|
11
|
+
import { SOUNDTRACK_EXTENSIONS } from './soundtrack.mjs';
|
|
12
|
+
|
|
13
|
+
const MIME = Object.freeze({
|
|
14
|
+
'.sjon': 'text/plain; charset=utf-8',
|
|
15
|
+
'.wgsl': 'text/plain; charset=utf-8',
|
|
16
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
17
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
18
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
19
|
+
'.json': 'application/json; charset=utf-8',
|
|
20
|
+
'.wasm': 'application/wasm',
|
|
21
|
+
'.png': 'image/png',
|
|
22
|
+
...SOUNDTRACK_EXTENSIONS,
|
|
23
|
+
'.bin': 'application/octet-stream',
|
|
24
|
+
'.css': 'text/css; charset=utf-8',
|
|
25
|
+
'.html': 'text/html; charset=utf-8',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const PLAYER_FILES = new Set(['index.html', 'page.mjs', 'boot.mjs', 'controller.mjs', 'player.css', 'devchannel.mjs']);
|
|
29
|
+
const APP_LIB_FILES = new Set(['score.mjs', 'show.mjs', 'soundtrack.mjs']);
|
|
30
|
+
const APP_ADAPTER_FILES = new Set(['webaudio.mjs']);
|
|
31
|
+
const HELLO_PLAYER_FILES = new Set(['run.js', 'join.js']);
|
|
32
|
+
|
|
33
|
+
export const ROUTE = Object.freeze({
|
|
34
|
+
page: '/',
|
|
35
|
+
config: '/config.json',
|
|
36
|
+
health: '/healthz',
|
|
37
|
+
channel: '/dev',
|
|
38
|
+
generationPrefix: '/gen/',
|
|
39
|
+
project: '/project/',
|
|
40
|
+
runtime: '/runtime/',
|
|
41
|
+
player: '/player/',
|
|
42
|
+
appLib: '/app/lib/',
|
|
43
|
+
appAdapters: '/app/adapters/',
|
|
44
|
+
appHello: '/app/hello/',
|
|
45
|
+
examples: '/examples/hello/player/',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export function mimeFor(relative) {
|
|
49
|
+
const slash = relative.lastIndexOf('/');
|
|
50
|
+
const dot = relative.lastIndexOf('.');
|
|
51
|
+
if (dot <= slash) return null;
|
|
52
|
+
return MIME[relative.slice(dot).toLowerCase()] ?? null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function failure(status, code, message) {
|
|
56
|
+
return { kind: 'error', status, code, message };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The path-tail grammar every asset mount shares: decode once, refuse NUL,
|
|
61
|
+
* backslashes, empty/dot segments and traversal, then answer the segments
|
|
62
|
+
* with their MIME decision. A mount may add its own filename allowlist.
|
|
63
|
+
*/
|
|
64
|
+
function assetSegments(tail, allow = null) {
|
|
65
|
+
const trimmed = tail.endsWith('/') ? tail.slice(0, -1) : tail;
|
|
66
|
+
if (trimmed === '') return failure(404, 'not_found', 'the mount has no asset name');
|
|
67
|
+
|
|
68
|
+
let decoded;
|
|
69
|
+
try {
|
|
70
|
+
decoded = decodeURIComponent(trimmed);
|
|
71
|
+
} catch {
|
|
72
|
+
return failure(400, 'bad_percent_encoding', 'the asset path has an invalid percent escape');
|
|
73
|
+
}
|
|
74
|
+
if (decoded.includes('\0') || decoded.includes('\\')) {
|
|
75
|
+
return failure(400, 'unsafe_path', 'the asset path carries a forbidden separator');
|
|
76
|
+
}
|
|
77
|
+
const segments = decoded.split('/');
|
|
78
|
+
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
|
79
|
+
return failure(400, 'unsafe_path', 'the asset path must stay inside its mount');
|
|
80
|
+
}
|
|
81
|
+
if (allow !== null && (segments.length !== 1 || !allow.has(segments[0]))) {
|
|
82
|
+
return failure(404, 'not_found', `the demo does not serve ${decoded} from this mount`);
|
|
83
|
+
}
|
|
84
|
+
const relative = segments.join('/');
|
|
85
|
+
const mime = mimeFor(relative);
|
|
86
|
+
if (mime === null && segments.at(-1).includes('.')) {
|
|
87
|
+
return failure(415, 'unsupported_media_type', `the demo has no MIME mapping for ${relative}`);
|
|
88
|
+
}
|
|
89
|
+
return { kind: 'asset', segments, relative, mime };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {string} rawUrl the request target, assumed origin-form
|
|
94
|
+
* @returns {Object}
|
|
95
|
+
*/
|
|
96
|
+
export function resolveRequest(rawUrl) {
|
|
97
|
+
if (typeof rawUrl !== 'string' || !rawUrl.startsWith('/')) {
|
|
98
|
+
return failure(404, 'not_found', 'not a local demo route');
|
|
99
|
+
}
|
|
100
|
+
const query = rawUrl.indexOf('?');
|
|
101
|
+
const path = query === -1 ? rawUrl : rawUrl.slice(0, query);
|
|
102
|
+
if (path === ROUTE.page) return { kind: 'page' };
|
|
103
|
+
if (path === ROUTE.config) return { kind: 'config' };
|
|
104
|
+
if (path === ROUTE.health) return { kind: 'health' };
|
|
105
|
+
if (path === ROUTE.channel) return { kind: 'channel' };
|
|
106
|
+
|
|
107
|
+
// One generation's immutable mount: /gen/<id>/config.json and
|
|
108
|
+
// /gen/<id>/project/<relative>. The id is one unpadded positive number so
|
|
109
|
+
// every generation has exactly one spelling.
|
|
110
|
+
if (path.startsWith(ROUTE.generationPrefix)) {
|
|
111
|
+
const tail = path.slice(ROUTE.generationPrefix.length);
|
|
112
|
+
const slash = tail.indexOf('/');
|
|
113
|
+
if (slash === -1) return failure(404, 'not_found', 'a generation route needs its config or project path');
|
|
114
|
+
const idText = tail.slice(0, slash);
|
|
115
|
+
if (!/^[1-9][0-9]*$/.test(idText)) {
|
|
116
|
+
return failure(404, 'not_found', 'a generation route needs an unpadded positive generation id');
|
|
117
|
+
}
|
|
118
|
+
const generation = Number(idText);
|
|
119
|
+
const rest = tail.slice(slash + 1);
|
|
120
|
+
if (rest === 'config.json') return { kind: 'generation-config', generation };
|
|
121
|
+
const projectPrefix = ROUTE.project.slice(1); // 'project/', no leading slash
|
|
122
|
+
if (rest.startsWith(projectPrefix)) {
|
|
123
|
+
const resolved = assetSegments(rest.slice(projectPrefix.length));
|
|
124
|
+
if (resolved.kind === 'error') return resolved;
|
|
125
|
+
return { ...resolved, kind: 'generation-asset', generation, mount: 'project' };
|
|
126
|
+
}
|
|
127
|
+
return failure(404, 'not_found', 'only config.json and project/ sit under a generation');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let mount;
|
|
131
|
+
let tail;
|
|
132
|
+
let allow = null;
|
|
133
|
+
if (path.startsWith(ROUTE.project)) {
|
|
134
|
+
mount = 'project';
|
|
135
|
+
tail = path.slice(ROUTE.project.length);
|
|
136
|
+
} else if (path.startsWith(ROUTE.runtime)) {
|
|
137
|
+
mount = 'runtime';
|
|
138
|
+
tail = path.slice(ROUTE.runtime.length);
|
|
139
|
+
} else if (path.startsWith(ROUTE.player)) {
|
|
140
|
+
mount = 'player';
|
|
141
|
+
tail = path.slice(ROUTE.player.length);
|
|
142
|
+
allow = PLAYER_FILES;
|
|
143
|
+
} else if (path.startsWith(ROUTE.appLib)) {
|
|
144
|
+
mount = 'app-lib';
|
|
145
|
+
tail = path.slice(ROUTE.appLib.length);
|
|
146
|
+
allow = APP_LIB_FILES;
|
|
147
|
+
} else if (path.startsWith(ROUTE.appAdapters)) {
|
|
148
|
+
mount = 'app-adapters';
|
|
149
|
+
tail = path.slice(ROUTE.appAdapters.length);
|
|
150
|
+
allow = APP_ADAPTER_FILES;
|
|
151
|
+
} else if (path.startsWith(ROUTE.appHello)) {
|
|
152
|
+
// The package's lib modules import the hello player by their sibling
|
|
153
|
+
// layout ('../hello/run.js'), which a browser resolves from /app/lib/
|
|
154
|
+
// to /app/hello/ — the same two allowlisted files the examples mount
|
|
155
|
+
// serves, reached the way the page's own module graph asks for them.
|
|
156
|
+
mount = 'examples';
|
|
157
|
+
tail = path.slice(ROUTE.appHello.length);
|
|
158
|
+
allow = HELLO_PLAYER_FILES;
|
|
159
|
+
} else if (path.startsWith(ROUTE.examples)) {
|
|
160
|
+
mount = 'examples';
|
|
161
|
+
tail = path.slice(ROUTE.examples.length);
|
|
162
|
+
allow = HELLO_PLAYER_FILES;
|
|
163
|
+
} else {
|
|
164
|
+
return failure(404, 'not_found', 'not a local demo route');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const resolved = assetSegments(tail, allow);
|
|
168
|
+
if (resolved.kind === 'error') return resolved;
|
|
169
|
+
return { ...resolved, mount };
|
|
170
|
+
}
|