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.
Files changed (71) hide show
  1. package/LICENSE +121 -0
  2. package/adapters/build-node.mjs +265 -0
  3. package/adapters/devserver.mjs +481 -0
  4. package/adapters/devsession.mjs +619 -0
  5. package/adapters/export-node.mjs +603 -0
  6. package/adapters/export-run.mjs +126 -0
  7. package/adapters/generations.mjs +183 -0
  8. package/adapters/http.mjs +294 -0
  9. package/adapters/launch.mjs +83 -0
  10. package/adapters/node-fs.mjs +80 -0
  11. package/adapters/pngine.mjs +87 -0
  12. package/adapters/snapshot.mjs +143 -0
  13. package/adapters/target.mjs +104 -0
  14. package/adapters/watch.mjs +355 -0
  15. package/adapters/webaudio.mjs +102 -0
  16. package/bin/animader.mjs +31 -0
  17. package/hello/join.js +46 -0
  18. package/hello/run.js +259 -0
  19. package/lib/build.mjs +126 -0
  20. package/lib/buildloop.mjs +254 -0
  21. package/lib/contract.mjs +371 -0
  22. package/lib/errors.mjs +51 -0
  23. package/lib/export.mjs +445 -0
  24. package/lib/new.mjs +304 -0
  25. package/lib/pipeline.mjs +247 -0
  26. package/lib/receipt.mjs +331 -0
  27. package/lib/rmtree.mjs +49 -0
  28. package/lib/routes.mjs +170 -0
  29. package/lib/score.mjs +434 -0
  30. package/lib/show.mjs +458 -0
  31. package/lib/soundtrack.mjs +67 -0
  32. package/lib/vendor.mjs +99 -0
  33. package/lib/wav.mjs +197 -0
  34. package/package.json +47 -4
  35. package/player/boot.mjs +792 -0
  36. package/player/controller.mjs +366 -0
  37. package/player/devchannel.mjs +306 -0
  38. package/player/index.html +35 -0
  39. package/player/page.mjs +134 -0
  40. package/player/player.css +393 -0
  41. package/player/release.mjs +76 -0
  42. package/src/commands/build.mjs +201 -0
  43. package/src/commands/dev.mjs +214 -0
  44. package/src/commands/doctor.mjs +348 -0
  45. package/src/commands/export.mjs +158 -0
  46. package/src/commands/new.mjs +164 -0
  47. package/src/commands/project.mjs +46 -0
  48. package/src/config.mjs +110 -0
  49. package/src/router.mjs +149 -0
  50. package/src/toolchain.mjs +691 -0
  51. package/starters/circle/README.md +50 -0
  52. package/starters/circle/animader.json +4 -0
  53. package/starters/circle/gitignore +9 -0
  54. package/starters/circle/kernels/circle.wgsl +32 -0
  55. package/starters/circle/program.sjon +34 -0
  56. package/starters/circle/timeline.piece.sjon +26 -0
  57. package/starters/ember/bake/spans.bin +0 -0
  58. package/starters/ember/kernels/curl.wgsl +10 -0
  59. package/starters/ember/kernels/ember.wgsl +10 -0
  60. package/starters/ember/kernels/hash.wgsl +12 -0
  61. package/starters/ember/kernels/simplex.wgsl +27 -0
  62. package/starters/ember/program.sjon +328 -0
  63. package/starters/ember/timeline.piece.sjon +34 -0
  64. package/toolchain/licenses/animader.txt +121 -0
  65. package/toolchain/licenses/esbuild.txt +21 -0
  66. package/toolchain/licenses/pacer.txt +121 -0
  67. package/toolchain/licenses/pngine.txt +121 -0
  68. package/toolchain/manifest.json +212 -0
  69. package/toolchain/prepare.mjs +446 -0
  70. package/toolchain/uniform-type-name.js +184 -0
  71. 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
+ }