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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The export operation behind both entries: `animader export` (the CLI
|
|
2
|
+
// command) and the legacy `node demo/export.mjs` compatibility adapter.
|
|
3
|
+
// It inspects the source project, resolves the output paths from the
|
|
4
|
+
// invocation directory, refuses paths that alias the authored inputs, and
|
|
5
|
+
// runs the portable staged export (lib/export.mjs) over the real node
|
|
6
|
+
// producers (export-node.mjs / build-node.mjs). The process boundary —
|
|
7
|
+
// argv parsing and the error:/remedy: printing — belongs to each entry,
|
|
8
|
+
// never to this operation.
|
|
9
|
+
//
|
|
10
|
+
// Stage 1 of the installed-toolchain plan moved this operation into the
|
|
11
|
+
// package: the CLI no longer imports across the checkout boundary.
|
|
12
|
+
|
|
13
|
+
import { realpath } from 'node:fs/promises';
|
|
14
|
+
import { dirname, resolve } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
assertSafeExportPaths,
|
|
18
|
+
createExportIo,
|
|
19
|
+
measureBytes,
|
|
20
|
+
prepareRelease,
|
|
21
|
+
} from './export-node.mjs';
|
|
22
|
+
import { createBuildProducers } from './build-node.mjs';
|
|
23
|
+
import { createNodeIo } from './node-fs.mjs';
|
|
24
|
+
import { inspectProject } from '../lib/contract.mjs';
|
|
25
|
+
import { DemoError } from '../lib/errors.mjs';
|
|
26
|
+
import { exportShow } from '../lib/export.mjs';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One export run: inspect the source project, resolve the output paths from
|
|
30
|
+
* the invocation directory, refuse paths that alias the authored inputs,
|
|
31
|
+
* then run the portable staged export over the real producers. The caller
|
|
32
|
+
* owns the process boundary through `say`/`warn` and may inject `producer`
|
|
33
|
+
* (the bake/derive/pack/bundle adapter) or `producers` (the build stage
|
|
34
|
+
* ports inside prepareRelease) for tests; the default is the real vendored
|
|
35
|
+
* producer chain, which fails loudly where the prepared runtime is absent.
|
|
36
|
+
*
|
|
37
|
+
* @param {Object} options
|
|
38
|
+
* @param {string} options.folder the project folder (realpath'd here)
|
|
39
|
+
* @param {number} options.seconds the resolved host show length
|
|
40
|
+
* @param {string} options.out the --out value, resolved from the invocation
|
|
41
|
+
* directory by this operation
|
|
42
|
+
* @param {string} [options.title] the first output line
|
|
43
|
+
* @param {(line: string) => void} [options.say] stdout line writer
|
|
44
|
+
* @param {(line: string) => void} [options.warn] stderr line writer
|
|
45
|
+
* @param {{prepare: Function}} [options.producer] test seam
|
|
46
|
+
* @param {object} [options.producers] test seam for the build stage ports
|
|
47
|
+
* @param {object} [options.toolchain] the toolchain descriptor
|
|
48
|
+
* (src/toolchain.mjs); the default is the contributor workspace toolchain
|
|
49
|
+
* @returns {Promise<object>} the exportShow result (manifest, report, paths)
|
|
50
|
+
*/
|
|
51
|
+
export async function runExport({
|
|
52
|
+
folder,
|
|
53
|
+
seconds,
|
|
54
|
+
out,
|
|
55
|
+
title = 'animader demo export',
|
|
56
|
+
say: outLine = (line) => process.stdout.write(`${line}\n`),
|
|
57
|
+
warn: warnLine = (line) => process.stderr.write(`${line}\n`),
|
|
58
|
+
producer,
|
|
59
|
+
producers,
|
|
60
|
+
toolchain = null,
|
|
61
|
+
}) {
|
|
62
|
+
let real;
|
|
63
|
+
try {
|
|
64
|
+
real = await realpath(folder);
|
|
65
|
+
} catch {
|
|
66
|
+
throw new DemoError('folder_missing', `the project folder does not exist: ${folder}`, {
|
|
67
|
+
exitCode: 3,
|
|
68
|
+
remedy: 'Pass the folder that holds program.sjon, timeline.piece.sjon and one of soundtrack.wav, soundtrack.ogg, soundtrack.opus.',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const io = createNodeIo();
|
|
73
|
+
const project = await inspectProject({ folder: real, io });
|
|
74
|
+
|
|
75
|
+
// A relative --out resolves from the invocation directory, not from the
|
|
76
|
+
// project: `export ./my-demo --out ./release` writes ./release.
|
|
77
|
+
const outDir = resolve(out);
|
|
78
|
+
const paths = {
|
|
79
|
+
outDir,
|
|
80
|
+
stageDir: `${outDir}.next`,
|
|
81
|
+
sizePath: `${outDir}.size.json`,
|
|
82
|
+
parentDir: dirname(outDir),
|
|
83
|
+
};
|
|
84
|
+
await assertSafeExportPaths({ folder: real, paths });
|
|
85
|
+
|
|
86
|
+
outLine(title);
|
|
87
|
+
outLine(`project: ${real}`);
|
|
88
|
+
outLine(`show: ${seconds}s (host policy)`);
|
|
89
|
+
outLine(`out: ${outDir}`);
|
|
90
|
+
|
|
91
|
+
let prepared = null;
|
|
92
|
+
const result = await exportShow({
|
|
93
|
+
project,
|
|
94
|
+
seconds,
|
|
95
|
+
paths,
|
|
96
|
+
io: createExportIo(),
|
|
97
|
+
producer: producer ?? {
|
|
98
|
+
async prepare() {
|
|
99
|
+
const buildProducers = producers ?? createBuildProducers(toolchain ? { toolchain } : {});
|
|
100
|
+
prepared = await prepareRelease({ project, seconds, paths, producers: buildProducers, toolchain });
|
|
101
|
+
return { files: prepared.files };
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
measure: measureBytes,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (prepared !== null) {
|
|
108
|
+
outLine(`runtime: ${prepared.meta.runtimeDigest}`);
|
|
109
|
+
outLine(`payload: assets/show.png (${prepared.meta.payload.bytes} bytes, sha256 ${prepared.meta.payload.sha256})`);
|
|
110
|
+
outLine(`score: assets/score.pacb (${prepared.meta.score.bytes} bytes, sha256 ${prepared.meta.score.sha256})`);
|
|
111
|
+
outLine(`pacer runtime: pacer-rt.wasm (${prepared.meta.pacerRtWasm.bytes} bytes, sha256 ${prepared.meta.pacerRtWasm.sha256})`);
|
|
112
|
+
outLine(`soundtrack: assets/${prepared.meta.soundtrack.name} (${prepared.meta.soundtrack.bytes} bytes, sha256 ${prepared.meta.soundtrack.sha256})`);
|
|
113
|
+
outLine(`bundle: app.mjs (${prepared.meta.bundle.bytes} bytes, sha256 ${prepared.meta.bundle.sha256}, esbuild ${prepared.meta.bundle.esbuild})`);
|
|
114
|
+
}
|
|
115
|
+
outLine(`manifest: ${result.manifest.files.length} files (self excluded)`);
|
|
116
|
+
for (const file of result.manifest.files) {
|
|
117
|
+
outLine(` ${file.path} (${file.bytes} bytes, sha256 ${file.sha256})`);
|
|
118
|
+
}
|
|
119
|
+
const { sums, display } = result.report;
|
|
120
|
+
outLine(`size: ${result.sizePath}`);
|
|
121
|
+
outLine(` all: ${sums.all.raw} B raw / ${sums.all.gzip9} B gzip-9 / ${sums.all.brotli11} B brotli-11 (${display.all.rawKiB} KiB raw)`);
|
|
122
|
+
outLine(` without music: ${sums.withoutMusic.raw} B raw / ${sums.withoutMusic.gzip9} B gzip-9 / ${sums.withoutMusic.brotli11} B brotli-11`);
|
|
123
|
+
outLine(` music: ${sums.music.raw} B raw / ${sums.music.gzip9} B gzip-9 / ${sums.music.brotli11} B brotli-11`);
|
|
124
|
+
outLine('next: serve the OUT folder; the size report sits beside it and is not part of the payload.');
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// The dev session's generation store (plan section 5). Each watch-mode
|
|
2
|
+
// build stages into its own directory under the session root; a successful
|
|
3
|
+
// build publishes that tree as an immutable, sequentially numbered
|
|
4
|
+
// generation, and the served "current" pointer swaps in one assignment —
|
|
5
|
+
// no filesystem-wide transaction is claimed, and none is needed: every
|
|
6
|
+
// generation directory is finished before it can be named, and nothing
|
|
7
|
+
// writes into it afterwards.
|
|
8
|
+
//
|
|
9
|
+
// Retention: a generation retired by a newer publish stays on disk until
|
|
10
|
+
// its in-flight responses finish AND a grace window has passed since its
|
|
11
|
+
// last activity — the grace covers a client that just learned the id (from
|
|
12
|
+
// /config.json or the notify channel) and is still about to fetch its
|
|
13
|
+
// pinned URLs. Sweeping happens on publish and on every response
|
|
14
|
+
// completion, so no timers keep the process alive; at most a couple of
|
|
15
|
+
// retired trees exist at a time. The current generation is never swept.
|
|
16
|
+
//
|
|
17
|
+
// The store is Node-bound on purpose (real directories under one session
|
|
18
|
+
// root); the coherence decisions it serves are the server's.
|
|
19
|
+
|
|
20
|
+
import { promises as fsp } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
import { isSoundtrackName, PLAYER_CONFIG_FORMATS, soundtrackNamesText } from '../lib/soundtrack.mjs';
|
|
24
|
+
import { serverError } from '../lib/errors.mjs';
|
|
25
|
+
import { removeTree } from './node-fs.mjs';
|
|
26
|
+
|
|
27
|
+
/** How long a retired generation with no in-flight responses is kept. */
|
|
28
|
+
export const DEFAULT_GENERATION_GRACE_MS = 30_000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {Object} options
|
|
32
|
+
* @param {string} options.root the session-owned root directory (outside
|
|
33
|
+
* the project tree); the store never removes anything outside it
|
|
34
|
+
* @param {number} [options.graceMs] retirement grace window
|
|
35
|
+
* @param {() => number} [options.now] the clock (a port, injectable for tests)
|
|
36
|
+
*/
|
|
37
|
+
export function createGenerationStore({ root, graceMs = DEFAULT_GENERATION_GRACE_MS, now = Date.now }) {
|
|
38
|
+
let nextId = 1;
|
|
39
|
+
let currentId = null;
|
|
40
|
+
let disposed = false;
|
|
41
|
+
/** @type {Map<number, {id: number, staging: string, dir: string, configBody: string,
|
|
42
|
+
* lastActivityAt: number, inflight: number, retiredAt: number|null}>} */
|
|
43
|
+
const generations = new Map();
|
|
44
|
+
|
|
45
|
+
function requireCurrentConfig({ seconds, soundtrack }) {
|
|
46
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0) {
|
|
47
|
+
throw serverError(
|
|
48
|
+
'missing_seconds',
|
|
49
|
+
`a generation needs the resolved host show length; got ${String(seconds)}`,
|
|
50
|
+
'The dev build resolves animader.json (or --seconds) before staging a generation.',
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (!isSoundtrackName(soundtrack)) {
|
|
54
|
+
throw serverError(
|
|
55
|
+
'invalid_soundtrack',
|
|
56
|
+
`a generation needs the inspected soundtrack selection; got ${JSON.stringify(soundtrack ?? null)}`,
|
|
57
|
+
`Pass the selected name from the project inspection: one of ${soundtrackNamesText()}.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function sweep(now) {
|
|
63
|
+
for (const [id, generation] of generations) {
|
|
64
|
+
if (id === currentId) continue;
|
|
65
|
+
if (generation.inflight > 0) continue;
|
|
66
|
+
if (now - generation.lastActivityAt < graceMs) continue;
|
|
67
|
+
generations.delete(id);
|
|
68
|
+
await removeTree(generation.staging);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
/** A fresh empty staging directory under the session root. */
|
|
74
|
+
async stagingRoot() {
|
|
75
|
+
if (disposed) throw serverError('generations_disposed', 'the dev session has stopped', 'Start a new animader dev session.');
|
|
76
|
+
return fsp.mkdtemp(join(root, 'staging-'));
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Publish one finished build tree as the next generation and retire the
|
|
81
|
+
* previous current. The tree must be complete: nothing may write into
|
|
82
|
+
* it after this call.
|
|
83
|
+
*
|
|
84
|
+
* @param {Object} options
|
|
85
|
+
* @param {string} options.staging the staging root this build owns
|
|
86
|
+
* @param {string} options.dir the finished generation tree (served as
|
|
87
|
+
* the generation's project files)
|
|
88
|
+
* @param {number} options.seconds this generation's resolved host length
|
|
89
|
+
* @param {string} options.soundtrack this generation's inspected selection
|
|
90
|
+
*/
|
|
91
|
+
async publish({ staging, dir, seconds, soundtrack }) {
|
|
92
|
+
if (disposed) {
|
|
93
|
+
await removeTree(staging);
|
|
94
|
+
throw serverError('generations_disposed', 'the dev session has stopped', 'Start a new animader dev session.');
|
|
95
|
+
}
|
|
96
|
+
requireCurrentConfig({ seconds, soundtrack });
|
|
97
|
+
// The served tree is canonicalised here, so no caller can hand the
|
|
98
|
+
// server a generation root that sits behind a symlink (macOS temp
|
|
99
|
+
// directories routinely do): containment and serving then agree on
|
|
100
|
+
// one spelling. A tree that cannot be realpath'd was never finished.
|
|
101
|
+
let realDir;
|
|
102
|
+
try {
|
|
103
|
+
realDir = await fsp.realpath(dir);
|
|
104
|
+
} catch {
|
|
105
|
+
await removeTree(staging);
|
|
106
|
+
throw serverError(
|
|
107
|
+
'generation_tree_missing',
|
|
108
|
+
`the finished generation tree cannot be resolved: ${dir}`,
|
|
109
|
+
'The build stages its tree before publishing; check the session root and retry the build.',
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const id = nextId;
|
|
113
|
+
nextId += 1;
|
|
114
|
+
const config = {
|
|
115
|
+
format: PLAYER_CONFIG_FORMATS.demo,
|
|
116
|
+
seconds,
|
|
117
|
+
soundtrack,
|
|
118
|
+
generation: id,
|
|
119
|
+
};
|
|
120
|
+
const generation = {
|
|
121
|
+
id,
|
|
122
|
+
staging,
|
|
123
|
+
dir: realDir,
|
|
124
|
+
configBody: `${JSON.stringify(config)}\n`,
|
|
125
|
+
lastActivityAt: now(),
|
|
126
|
+
inflight: 0,
|
|
127
|
+
retiredAt: null,
|
|
128
|
+
};
|
|
129
|
+
generations.set(id, generation);
|
|
130
|
+
if (currentId !== null && generations.has(currentId)) {
|
|
131
|
+
generations.get(currentId).retiredAt = now();
|
|
132
|
+
}
|
|
133
|
+
currentId = id;
|
|
134
|
+
await sweep(now());
|
|
135
|
+
return generation;
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
/** The current generation, or null before the first successful build. */
|
|
139
|
+
current() {
|
|
140
|
+
if (currentId === null) return null;
|
|
141
|
+
return generations.get(currentId) ?? null;
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
/** One retained generation by id, or null once it has been swept. */
|
|
145
|
+
get(id) {
|
|
146
|
+
return generations.get(id) ?? null;
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
/** The page URL that pins a session to one generation. */
|
|
150
|
+
pageUrl(id) {
|
|
151
|
+
return `/?gen=${id}`;
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
/** Mark one in-flight response onto a generation. */
|
|
155
|
+
retain(id) {
|
|
156
|
+
const generation = generations.get(id);
|
|
157
|
+
if (generation) generation.inflight += 1;
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
/** Finish one in-flight response and sweep what became collectable. */
|
|
161
|
+
release(id) {
|
|
162
|
+
const generation = generations.get(id);
|
|
163
|
+
if (generation && generation.inflight > 0) generation.inflight -= 1;
|
|
164
|
+
if (generation) generation.lastActivityAt = now();
|
|
165
|
+
return sweep(now());
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
/** Refresh the grace window for activity on a generation. */
|
|
169
|
+
touch(id) {
|
|
170
|
+
const generation = generations.get(id);
|
|
171
|
+
if (generation) generation.lastActivityAt = now();
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
/** Remove the whole session root; the store is unusable afterwards. */
|
|
175
|
+
async dispose() {
|
|
176
|
+
if (disposed) return;
|
|
177
|
+
disposed = true;
|
|
178
|
+
currentId = null;
|
|
179
|
+
generations.clear();
|
|
180
|
+
await removeTree(root);
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// Node HTTP adapter. It binds one loopback server, serves the actual player
|
|
2
|
+
// page, the host config, the requested project root, the prepared runtime root
|
|
3
|
+
// and the narrow, allowlisted app/example modules, refuses symlink escapes after
|
|
4
|
+
// canonicalization, and exposes an idempotent close() that owns nothing but
|
|
5
|
+
// this server. It is not a project security sandbox and makes no claim about
|
|
6
|
+
// unknown MIME types: those are 415.
|
|
7
|
+
//
|
|
8
|
+
// The page route is the actual player; its module imports are served from
|
|
9
|
+
// separate narrow mounts so the checkout root is never a web root.
|
|
10
|
+
|
|
11
|
+
import { createReadStream } from 'node:fs';
|
|
12
|
+
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
13
|
+
import { createServer } from 'node:http';
|
|
14
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
import { serverError } from '../lib/errors.mjs';
|
|
18
|
+
import { resolveRequest } from '../lib/routes.mjs';
|
|
19
|
+
import { isSoundtrackName, PLAYER_CONFIG_FORMATS, soundtrackNamesText } from '../lib/soundtrack.mjs';
|
|
20
|
+
|
|
21
|
+
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
|
|
23
|
+
const COMMON_HEADERS = Object.freeze({
|
|
24
|
+
'X-Content-Type-Options': 'nosniff',
|
|
25
|
+
'Cache-Control': 'no-store',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** The plain-text responder every demo server shares (exported for the dev generation server). */
|
|
29
|
+
export function send(res, method, status, body, headers = {}) {
|
|
30
|
+
const bytes = Buffer.isBuffer(body) ? body : Buffer.from(body);
|
|
31
|
+
res.writeHead(status, {
|
|
32
|
+
...COMMON_HEADERS,
|
|
33
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
34
|
+
'Content-Length': String(bytes.byteLength),
|
|
35
|
+
...headers,
|
|
36
|
+
});
|
|
37
|
+
if (method === 'HEAD') res.end();
|
|
38
|
+
else res.end(bytes);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function notFound(res, method) {
|
|
42
|
+
send(res, method, 404, 'not found\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Containment is decided in one canonical space. Each root is realpath'd
|
|
46
|
+
// once and cached (roots are stable for a server's life), so a root that
|
|
47
|
+
// merely sits behind a symlink — macOS hands every temp directory out as
|
|
48
|
+
// /var → /private/var — still contains its realpath'd candidates. A root
|
|
49
|
+
// that cannot be realpath'd at all answers 404, never a throw.
|
|
50
|
+
const CANONICAL_ROOT_CACHE_LIMIT = 256;
|
|
51
|
+
const canonicalRoots = new Map();
|
|
52
|
+
|
|
53
|
+
/** The canonical spelling of one served root, or null when it cannot be
|
|
54
|
+
* realpath'd (the request then answers 404). */
|
|
55
|
+
function canonicalRoot(root) {
|
|
56
|
+
let cached = canonicalRoots.get(root);
|
|
57
|
+
if (cached === undefined) {
|
|
58
|
+
cached = realpath(root).catch(() => null);
|
|
59
|
+
if (canonicalRoots.size >= CANONICAL_ROOT_CACHE_LIMIT) canonicalRoots.clear();
|
|
60
|
+
canonicalRoots.set(root, cached);
|
|
61
|
+
}
|
|
62
|
+
return cached;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Resolve segments under one root, refusing escapes after canonicalization:
|
|
66
|
+
* both the lexical pre-check and the post-realpath check compare against the
|
|
67
|
+
* root's canonical spelling. */
|
|
68
|
+
export async function inside(root, relativeSegments) {
|
|
69
|
+
const base = await canonicalRoot(root);
|
|
70
|
+
if (base === null) return null;
|
|
71
|
+
const candidate = resolve(base, ...relativeSegments);
|
|
72
|
+
if (candidate !== base && !candidate.startsWith(`${base}${sep}`)) return null;
|
|
73
|
+
let real;
|
|
74
|
+
try {
|
|
75
|
+
real = await realpath(candidate);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
if (real !== base && !real.startsWith(`${base}${sep}`)) return null;
|
|
81
|
+
return real;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** realpath one required root or answer the named server error. */
|
|
85
|
+
export async function requireRoot(root, code, message, remedy) {
|
|
86
|
+
try {
|
|
87
|
+
return await realpath(root);
|
|
88
|
+
} catch {
|
|
89
|
+
throw serverError(code, message, remedy);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {{
|
|
95
|
+
* projectRoot: string,
|
|
96
|
+
* runtimeRoot: string,
|
|
97
|
+
* seconds: number,
|
|
98
|
+
* host?: string,
|
|
99
|
+
* port?: number,
|
|
100
|
+
* pageRoot?: string,
|
|
101
|
+
* appLibRoot?: string,
|
|
102
|
+
* appAdaptersRoot?: string,
|
|
103
|
+
* examplesRoot?: string,
|
|
104
|
+
* }} options
|
|
105
|
+
*/
|
|
106
|
+
export async function startServer({
|
|
107
|
+
projectRoot,
|
|
108
|
+
runtimeRoot,
|
|
109
|
+
seconds,
|
|
110
|
+
soundtrack,
|
|
111
|
+
host = '127.0.0.1',
|
|
112
|
+
port = 0,
|
|
113
|
+
pageRoot = join(PKG_ROOT, 'player'),
|
|
114
|
+
appLibRoot = join(PKG_ROOT, 'lib'),
|
|
115
|
+
appAdaptersRoot = join(PKG_ROOT, 'adapters'),
|
|
116
|
+
examplesRoot = join(PKG_ROOT, 'hello'),
|
|
117
|
+
}) {
|
|
118
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0) {
|
|
119
|
+
throw serverError(
|
|
120
|
+
'missing_seconds',
|
|
121
|
+
'the server needs the host show length from --seconds',
|
|
122
|
+
'Pass the host show length, for example: node demo/show.mjs <folder> --seconds 8',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const projectReal = await requireRoot(
|
|
127
|
+
projectRoot,
|
|
128
|
+
'missing_project_root',
|
|
129
|
+
`the project folder does not exist: ${projectRoot}`,
|
|
130
|
+
'Pass the folder that holds program.sjon, timeline.piece.sjon and one of soundtrack.wav, soundtrack.ogg, soundtrack.opus.',
|
|
131
|
+
);
|
|
132
|
+
const runtimeReal = await requireRoot(
|
|
133
|
+
runtimeRoot,
|
|
134
|
+
'runtime_missing',
|
|
135
|
+
'the prepared runtime bundle is missing',
|
|
136
|
+
'Run the one-time prepare: node demo/prepare.mjs',
|
|
137
|
+
);
|
|
138
|
+
const pageReal = await requireRoot(
|
|
139
|
+
pageRoot,
|
|
140
|
+
'page_missing',
|
|
141
|
+
'the player page folder is missing',
|
|
142
|
+
'Restore the package player folder (npm/cli/player).',
|
|
143
|
+
);
|
|
144
|
+
const appLibReal = await requireRoot(
|
|
145
|
+
appLibRoot,
|
|
146
|
+
'app_missing',
|
|
147
|
+
'the player core module folder is missing',
|
|
148
|
+
'Restore the package lib folder (npm/cli/lib).',
|
|
149
|
+
);
|
|
150
|
+
const appAdaptersReal = await requireRoot(
|
|
151
|
+
appAdaptersRoot,
|
|
152
|
+
'app_missing',
|
|
153
|
+
'the player adapter folder is missing',
|
|
154
|
+
'Restore the package adapters folder (npm/cli/adapters).',
|
|
155
|
+
);
|
|
156
|
+
const examplesReal = await requireRoot(
|
|
157
|
+
examplesRoot,
|
|
158
|
+
'examples_missing',
|
|
159
|
+
'the hello player source folder is missing',
|
|
160
|
+
'Restore the package hello player folder (npm/cli/hello); the page imports its run and join modules.',
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
let pageHtml;
|
|
164
|
+
try {
|
|
165
|
+
pageHtml = await readFile(join(pageReal, 'index.html'));
|
|
166
|
+
} catch {
|
|
167
|
+
throw serverError('page_missing', 'the player page is missing', 'Restore the package player page (npm/cli/player/index.html).');
|
|
168
|
+
}
|
|
169
|
+
if (!isSoundtrackName(soundtrack)) {
|
|
170
|
+
throw serverError(
|
|
171
|
+
'invalid_soundtrack',
|
|
172
|
+
`the server needs the inspected soundtrack selection; got ${JSON.stringify(soundtrack ?? null)}`,
|
|
173
|
+
`Pass the selected name from the project inspection: one of ${soundtrackNamesText()}.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const configBody = `${JSON.stringify({ format: PLAYER_CONFIG_FORMATS.demo, seconds, soundtrack })}\n`;
|
|
177
|
+
const roots = {
|
|
178
|
+
project: projectReal,
|
|
179
|
+
runtime: runtimeReal,
|
|
180
|
+
player: pageReal,
|
|
181
|
+
'app-lib': appLibReal,
|
|
182
|
+
'app-adapters': appAdaptersReal,
|
|
183
|
+
examples: examplesReal,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const server = createServer((req, res) => {
|
|
187
|
+
handle(req, res).catch(() => {
|
|
188
|
+
if (!res.headersSent) send(res, req.method, 500, 'internal server error\n');
|
|
189
|
+
else res.destroy();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
async function handle(req, res) {
|
|
194
|
+
const method = req.method ?? 'GET';
|
|
195
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
196
|
+
send(res, method, 405, 'method not allowed\n', { Allow: 'GET, HEAD' });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const route = resolveRequest(req.url ?? '/');
|
|
200
|
+
if (route.kind === 'page') {
|
|
201
|
+
send(res, method, 200, pageHtml, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (route.kind === 'config') {
|
|
205
|
+
send(res, method, 200, configBody, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (route.kind === 'health') {
|
|
209
|
+
send(res, method, 200, '{"status":"ok","stage":"preflight"}\n', { 'Content-Type': 'application/json; charset=utf-8' });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (route.kind === 'error') {
|
|
213
|
+
send(res, method, route.status, `${route.code}: ${route.message}\n`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const root = roots[route.mount];
|
|
218
|
+
if (root === undefined) {
|
|
219
|
+
notFound(res, method);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const real = await inside(root, route.segments);
|
|
223
|
+
if (real === null) {
|
|
224
|
+
notFound(res, method);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const info = await stat(real);
|
|
228
|
+
if (!info.isFile()) {
|
|
229
|
+
notFound(res, method);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (route.mime === null) {
|
|
233
|
+
send(res, method, 415, `unsupported_media_type: the demo has no MIME mapping for ${route.relative}\n`);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const headers = {
|
|
237
|
+
...COMMON_HEADERS,
|
|
238
|
+
'Content-Type': route.mime,
|
|
239
|
+
'Content-Length': String(info.size),
|
|
240
|
+
};
|
|
241
|
+
if (method === 'HEAD') {
|
|
242
|
+
res.writeHead(200, headers);
|
|
243
|
+
res.end();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
res.writeHead(200, headers);
|
|
247
|
+
const stream = createReadStream(real);
|
|
248
|
+
stream.on('error', () => {
|
|
249
|
+
if (!res.writableEnded) res.destroy();
|
|
250
|
+
});
|
|
251
|
+
stream.pipe(res);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
256
|
+
const onError = (error) => {
|
|
257
|
+
server.off('listening', onListening);
|
|
258
|
+
rejectListen(error);
|
|
259
|
+
};
|
|
260
|
+
const onListening = () => {
|
|
261
|
+
server.off('error', onError);
|
|
262
|
+
resolveListen();
|
|
263
|
+
};
|
|
264
|
+
server.once('error', onError);
|
|
265
|
+
server.once('listening', onListening);
|
|
266
|
+
server.listen(port, host);
|
|
267
|
+
});
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (error.code === 'EADDRINUSE') {
|
|
270
|
+
throw serverError(
|
|
271
|
+
'port_in_use',
|
|
272
|
+
`port ${port} on ${host} is already in use`,
|
|
273
|
+
'Retry with --port 0 (the default) to choose a free port, or pass another --port.',
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
if (error.code === 'EACCES') {
|
|
277
|
+
throw serverError('port_denied', `the operating system refused port ${port} on ${host}`, 'Retry with --port 0 to choose an unprivileged free port.');
|
|
278
|
+
}
|
|
279
|
+
throw serverError('server_bind', `could not bind ${host}:${port}: ${error.message}`, 'Retry with --port 0, or inspect the address family and permissions.');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const address = server.address();
|
|
283
|
+
const actualPort = typeof address === 'object' && address !== null ? address.port : port;
|
|
284
|
+
let closed = false;
|
|
285
|
+
async function close() {
|
|
286
|
+
if (closed) return;
|
|
287
|
+
closed = true;
|
|
288
|
+
const done = new Promise((resolveClose) => server.close(resolveClose));
|
|
289
|
+
server.closeAllConnections?.();
|
|
290
|
+
await done;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { url: `http://${host}:${actualPort}/`, host, port: actualPort, server, close };
|
|
294
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Chromium launch adapter. It spawns a browser with the URL as an argv entry,
|
|
2
|
+
// detached and without a shell. It never reports a successful launch when the
|
|
3
|
+
// executable could not start; the caller prints the URL so the show remains
|
|
4
|
+
// reachable by hand.
|
|
5
|
+
//
|
|
6
|
+
// Platform-aware (plan section 5): darwin launches the Chromium-family
|
|
7
|
+
// application bundles directly — the executable inside the .app, never a
|
|
8
|
+
// shell helper — and falls back to the system `open` launcher last; every
|
|
9
|
+
// other platform keeps the existing PATH candidate behaviour, and the
|
|
10
|
+
// explicit ANIMADER_CHROMIUM override wins everywhere for contributors. No
|
|
11
|
+
// browser is installed or bundled, and a spawn proves nothing about WebGPU:
|
|
12
|
+
// the page is the diagnostic that earns READY.
|
|
13
|
+
|
|
14
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
15
|
+
|
|
16
|
+
export const CHROMIUM_CANDIDATES = Object.freeze([
|
|
17
|
+
'chromium',
|
|
18
|
+
'chromium-browser',
|
|
19
|
+
'google-chrome',
|
|
20
|
+
'google-chrome-stable',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The darwin candidates, in order: the Chromium-family application bundles
|
|
25
|
+
* launched directly (no shell), then the system default-browser launcher.
|
|
26
|
+
*/
|
|
27
|
+
export const DARWIN_BROWSER_CANDIDATES = Object.freeze([
|
|
28
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
29
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
30
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
31
|
+
'open',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/** The candidate list one platform tries, before any explicit override. */
|
|
35
|
+
export function browserCandidates(platform = process.platform) {
|
|
36
|
+
return platform === 'darwin' ? DARWIN_BROWSER_CANDIDATES : CHROMIUM_CANDIDATES;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function loopback(url) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = new URL(url);
|
|
42
|
+
return parsed.protocol === 'http:' && (parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost');
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string} url
|
|
50
|
+
* @param {{spawnImpl?: Function, env?: Object, candidates?: string[], platform?: string}} [options]
|
|
51
|
+
* @returns {Promise<{ok: true, command: string}|{ok: false, reason: string}>}
|
|
52
|
+
*/
|
|
53
|
+
export async function launchBrowser(url, {
|
|
54
|
+
spawnImpl = nodeSpawn,
|
|
55
|
+
env = process.env,
|
|
56
|
+
candidates = null,
|
|
57
|
+
platform = process.platform,
|
|
58
|
+
} = {}) {
|
|
59
|
+
if (!loopback(url)) {
|
|
60
|
+
return { ok: false, reason: 'refusing to launch a non-loopback URL' };
|
|
61
|
+
}
|
|
62
|
+
const list = typeof env.ANIMADER_CHROMIUM === 'string' && env.ANIMADER_CHROMIUM !== ''
|
|
63
|
+
? [env.ANIMADER_CHROMIUM]
|
|
64
|
+
: (candidates ?? browserCandidates(platform));
|
|
65
|
+
const failures = [];
|
|
66
|
+
for (const command of list) {
|
|
67
|
+
try {
|
|
68
|
+
const child = spawnImpl(command, [url], { detached: true, stdio: 'ignore' });
|
|
69
|
+
await new Promise((resolve, reject) => {
|
|
70
|
+
child.once('spawn', resolve);
|
|
71
|
+
child.once('error', reject);
|
|
72
|
+
});
|
|
73
|
+
child.unref();
|
|
74
|
+
return { ok: true, command };
|
|
75
|
+
} catch (error) {
|
|
76
|
+
failures.push(`${command}: ${error?.message ?? String(error)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
reason: failures.length > 0 ? failures.join('; ') : 'no Chromium executable found',
|
|
82
|
+
};
|
|
83
|
+
}
|