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,481 @@
|
|
|
1
|
+
// The dev generation server (plan section 5). It serves the same narrow
|
|
2
|
+
// mounts as the demo server — the player page surface, the prepared
|
|
3
|
+
// runtime, the allowlisted app/example modules — but every project-derived
|
|
4
|
+
// byte comes from one immutable, generation-pinned tree: `/gen/<id>/…`
|
|
5
|
+
// URLs are answered from that generation's own directory with in-flight
|
|
6
|
+
// tracking, and `/config.json` is the current generation's rendered config
|
|
7
|
+
// read once per request, so parallel requests can never mix an old payload
|
|
8
|
+
// with a new timeline or audio asset. Before the first successful build
|
|
9
|
+
// the root serves a small self-contained error/recovery page wired to the
|
|
10
|
+
// development channel — a missing payload never has to reach READY.
|
|
11
|
+
//
|
|
12
|
+
// `/dev` is the development-only notification channel (server-sent
|
|
13
|
+
// events): each connection is greeted with the current generation, and
|
|
14
|
+
// `channel.broadcast` is the session's loudspeaker for reload/stale
|
|
15
|
+
// events. The channel exists only in dev; nothing here is imported by the
|
|
16
|
+
// exported player.
|
|
17
|
+
//
|
|
18
|
+
// Failures keep the demo's identity: a missing runtime root, a refused
|
|
19
|
+
// port and a missing page surface carry the same codes and remedies as the
|
|
20
|
+
// demo server, and close() ends every channel connection before closing
|
|
21
|
+
// the socket.
|
|
22
|
+
|
|
23
|
+
import { createReadStream } from 'node:fs';
|
|
24
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
25
|
+
import { createServer } from 'node:http';
|
|
26
|
+
import { dirname, join, resolve } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
import { inside, requireRoot, send } from './http.mjs';
|
|
30
|
+
import { serverError } from '../lib/errors.mjs';
|
|
31
|
+
import { resolveRequest } from '../lib/routes.mjs';
|
|
32
|
+
|
|
33
|
+
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
34
|
+
|
|
35
|
+
function escapeHtml(text) {
|
|
36
|
+
return String(text)
|
|
37
|
+
.replaceAll('&', '&')
|
|
38
|
+
.replaceAll('<', '<')
|
|
39
|
+
.replaceAll('>', '>')
|
|
40
|
+
.replaceAll('"', '"');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The self-contained page served while no successful generation exists.
|
|
45
|
+
* It carries no external asset and no player: the build error, its remedy,
|
|
46
|
+
* the promise that watching continues, and the tiny channel client that
|
|
47
|
+
* reloads onto the first successful generation.
|
|
48
|
+
*/
|
|
49
|
+
export function errorPageHtml(error) {
|
|
50
|
+
const failure = error ?? { code: 'build_failed', message: 'the first build has not succeeded yet', remedy: '' };
|
|
51
|
+
const remedy = failure.remedy ? `<p class="remedy">${escapeHtml(failure.remedy)}</p>` : '';
|
|
52
|
+
return `<!DOCTYPE html>
|
|
53
|
+
<html lang="en">
|
|
54
|
+
<head>
|
|
55
|
+
<meta charset="utf-8">
|
|
56
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
57
|
+
<meta name="color-scheme" content="dark">
|
|
58
|
+
<title>animader dev — build failed</title>
|
|
59
|
+
<link rel="icon" href="data:,">
|
|
60
|
+
<style>
|
|
61
|
+
:root { color-scheme: dark; }
|
|
62
|
+
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
63
|
+
background: #0a0608; color: #d8cfc4; font: 14px/1.4 system-ui, sans-serif; }
|
|
64
|
+
main { max-width: 42rem; padding: 24px; }
|
|
65
|
+
h1 { font-size: 16px; color: #f0b27a; margin: 0 0 8px; }
|
|
66
|
+
p { margin: 6px 0; }
|
|
67
|
+
code, pre { font: 12px/1.4 ui-monospace, monospace; color: #8f857a; }
|
|
68
|
+
.banner { border: 1px solid #3a2a33; background: #140d11; padding: 12px; margin-top: 12px; }
|
|
69
|
+
.remedy { color: #d8cfc4; }
|
|
70
|
+
</style>
|
|
71
|
+
</head>
|
|
72
|
+
<body>
|
|
73
|
+
<main>
|
|
74
|
+
<h1>animader dev — build failed</h1>
|
|
75
|
+
<div class="banner" role="alert">
|
|
76
|
+
<p><code>${escapeHtml(failure.code ?? '')}</code></p>
|
|
77
|
+
<p>${escapeHtml(failure.message ?? String(failure))}</p>
|
|
78
|
+
${remedy}
|
|
79
|
+
</div>
|
|
80
|
+
<p>Watching continues. This page reloads itself when a build succeeds; fix the named file and save.</p>
|
|
81
|
+
<noscript>Reload the page after fixing the project.</noscript>
|
|
82
|
+
</main>
|
|
83
|
+
<script type="module">
|
|
84
|
+
const status = document.querySelector('.banner');
|
|
85
|
+
const update = (detail) => {
|
|
86
|
+
if (!detail?.error) return;
|
|
87
|
+
status.replaceChildren();
|
|
88
|
+
const code = document.createElement('p');
|
|
89
|
+
code.append(document.createElement('code')).textContent = detail.error.code ?? '';
|
|
90
|
+
const message = document.createElement('p');
|
|
91
|
+
message.textContent = detail.error.message ?? '';
|
|
92
|
+
status.append(code, message);
|
|
93
|
+
if (detail.error.remedy) {
|
|
94
|
+
const remedy = document.createElement('p');
|
|
95
|
+
remedy.className = 'remedy';
|
|
96
|
+
remedy.textContent = detail.error.remedy;
|
|
97
|
+
status.append(remedy);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const events = new EventSource('/dev');
|
|
101
|
+
events.addEventListener('reload', (event) => {
|
|
102
|
+
const detail = JSON.parse(event.data);
|
|
103
|
+
if (detail?.url) window.location.replace(detail.url);
|
|
104
|
+
});
|
|
105
|
+
events.addEventListener('stale', (event) => {
|
|
106
|
+
try { update(JSON.parse(event.data)); } catch { /* the banner keeps the last message */ }
|
|
107
|
+
});
|
|
108
|
+
</script>
|
|
109
|
+
</body>
|
|
110
|
+
</html>
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {{
|
|
116
|
+
* generations: object, latestError: () => (Object|null),
|
|
117
|
+
* runtimeRoot?: string, runtimeFiles?: Array<{path: string, source: string}>,
|
|
118
|
+
* onFault?: (line: string) => void,
|
|
119
|
+
* host?: string, port?: number, pageRoot?: string, appLibRoot?: string,
|
|
120
|
+
* appAdaptersRoot?: string, examplesRoot?: string,
|
|
121
|
+
* }} options
|
|
122
|
+
*/
|
|
123
|
+
export async function startDevServer({
|
|
124
|
+
generations,
|
|
125
|
+
runtimeRoot,
|
|
126
|
+
runtimeFiles,
|
|
127
|
+
latestError,
|
|
128
|
+
onFault = (line) => process.stderr.write(`${line}\n`),
|
|
129
|
+
host = '127.0.0.1',
|
|
130
|
+
port = 0,
|
|
131
|
+
pageRoot = join(PKG_ROOT, 'player'),
|
|
132
|
+
appLibRoot = join(PKG_ROOT, 'lib'),
|
|
133
|
+
appAdaptersRoot = join(PKG_ROOT, 'adapters'),
|
|
134
|
+
examplesRoot = join(PKG_ROOT, 'hello'),
|
|
135
|
+
}) {
|
|
136
|
+
if (typeof latestError !== 'function') {
|
|
137
|
+
throw serverError('missing_error_state', 'the dev server needs a latestError provider', 'Pass the dev session error state provider.');
|
|
138
|
+
}
|
|
139
|
+
// The runtime mount has exactly one of two shapes: the contributor
|
|
140
|
+
// workspace's prepared bundle directory, or the installed toolchain's
|
|
141
|
+
// explicit file map (each /runtime/<path> answered by the package file
|
|
142
|
+
// the release manifest resolved — no directory, no traversal).
|
|
143
|
+
let runtimeMount;
|
|
144
|
+
if (runtimeFiles !== undefined) {
|
|
145
|
+
if (!Array.isArray(runtimeFiles) || runtimeFiles.length === 0) {
|
|
146
|
+
throw serverError('runtime_missing', 'the installed runtime file map is empty', 'Reinstall animader-cli; the toolchain manifest describes no runtime files.');
|
|
147
|
+
}
|
|
148
|
+
const map = new Map();
|
|
149
|
+
for (const entry of runtimeFiles) {
|
|
150
|
+
const info = await stat(entry.source).catch(() => null);
|
|
151
|
+
if (info === null || !info.isFile()) {
|
|
152
|
+
throw serverError('runtime_missing', `the installed runtime file ${entry.path} is missing at ${entry.source}`, 'Reinstall animader-cli; the toolchain manifest resolves this file.');
|
|
153
|
+
}
|
|
154
|
+
map.set(entry.path, entry.source);
|
|
155
|
+
}
|
|
156
|
+
runtimeMount = { kind: 'map', map };
|
|
157
|
+
} else {
|
|
158
|
+
const runtimeReal = await requireRoot(
|
|
159
|
+
runtimeRoot,
|
|
160
|
+
'runtime_missing',
|
|
161
|
+
'the prepared runtime bundle is missing',
|
|
162
|
+
'Run the one-time prepare: node demo/prepare.mjs',
|
|
163
|
+
);
|
|
164
|
+
runtimeMount = { kind: 'dir', root: runtimeReal };
|
|
165
|
+
}
|
|
166
|
+
const pageReal = await requireRoot(pageRoot, 'page_missing', 'the player page folder is missing', 'Restore the package player folder (npm/cli/player).');
|
|
167
|
+
const appLibReal = await requireRoot(appLibRoot, 'app_missing', 'the player core module folder is missing', 'Restore the package lib folder (npm/cli/lib).');
|
|
168
|
+
const appAdaptersReal = await requireRoot(appAdaptersRoot, 'app_missing', 'the player adapter folder is missing', 'Restore the package adapters folder (npm/cli/adapters).');
|
|
169
|
+
const examplesReal = await requireRoot(
|
|
170
|
+
examplesRoot,
|
|
171
|
+
'examples_missing',
|
|
172
|
+
'the hello player source folder is missing',
|
|
173
|
+
'Restore the package hello player folder (npm/cli/hello); the page imports its run and join modules.',
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
let pageHtml;
|
|
177
|
+
try {
|
|
178
|
+
pageHtml = await readFile(join(pageReal, 'index.html'));
|
|
179
|
+
} catch {
|
|
180
|
+
throw serverError('page_missing', 'the player page is missing', 'Restore the package player page (npm/cli/player/index.html).');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const roots = {
|
|
184
|
+
runtime: runtimeMount,
|
|
185
|
+
player: pageReal,
|
|
186
|
+
'app-lib': appLibReal,
|
|
187
|
+
'app-adapters': appAdaptersReal,
|
|
188
|
+
examples: examplesReal,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** The directory a mount serves by containment under: plain roots answer
|
|
192
|
+
* themselves, a dir-mount answers its root, and a map mount has no
|
|
193
|
+
* directory at all (its rows serve by exact lookup only). */
|
|
194
|
+
function containmentRoot(mount) {
|
|
195
|
+
const entry = roots[mount];
|
|
196
|
+
if (entry === undefined) return undefined;
|
|
197
|
+
if (typeof entry === 'string') return entry;
|
|
198
|
+
return entry.kind === 'dir' ? entry.root : undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** A server-side fault is never silent: one line to the dev log and a
|
|
202
|
+
* named body, so an unexpected shape is traceable from both ends. */
|
|
203
|
+
function faultText(error, where) {
|
|
204
|
+
const message = String(error?.message ?? error).replace(/\s+/g, ' ').trim().slice(0, 300);
|
|
205
|
+
onFault(`dev server fault (${where}): internal_error: ${message}`);
|
|
206
|
+
return `internal_error: ${message}\n`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The development-only channel: one set of open event-stream responses.
|
|
210
|
+
const connections = new Set();
|
|
211
|
+
function frame(name, payload) {
|
|
212
|
+
return `event: ${name}\ndata: ${JSON.stringify(payload)}\n\n`;
|
|
213
|
+
}
|
|
214
|
+
function helloPayload() {
|
|
215
|
+
const current = generations.current();
|
|
216
|
+
const error = latestError();
|
|
217
|
+
return {
|
|
218
|
+
generation: current ? current.id : null,
|
|
219
|
+
url: current ? generations.pageUrl(current.id) : null,
|
|
220
|
+
error: error ? { code: error.code ?? null, message: error.message, remedy: error.remedy ?? null } : null,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const channel = {
|
|
224
|
+
connections,
|
|
225
|
+
helloPayload,
|
|
226
|
+
broadcast(name, payload) {
|
|
227
|
+
const text = frame(name, payload);
|
|
228
|
+
for (const res of connections) {
|
|
229
|
+
if (!res.destroyed) res.write(text);
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/** Serve one file from a pinned generation with in-flight retention. */
|
|
235
|
+
async function serveGenerationFile(res, method, generation, route) {
|
|
236
|
+
generations.retain(generation.id);
|
|
237
|
+
generations.touch(generation.id);
|
|
238
|
+
let released = false;
|
|
239
|
+
const release = () => {
|
|
240
|
+
if (released) return;
|
|
241
|
+
released = true;
|
|
242
|
+
void generations.release(generation.id);
|
|
243
|
+
};
|
|
244
|
+
res.on('finish', release);
|
|
245
|
+
res.on('close', release);
|
|
246
|
+
try {
|
|
247
|
+
const real = await inside(generation.dir, route.segments);
|
|
248
|
+
if (real === null) {
|
|
249
|
+
send(res, method, 404, 'not found\n');
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const info = await stat(real);
|
|
253
|
+
if (!info.isFile()) {
|
|
254
|
+
send(res, method, 404, 'not found\n');
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (route.mime === null) {
|
|
258
|
+
send(res, method, 415, `unsupported_media_type: the demo has no MIME mapping for ${route.relative}\n`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const headers = {
|
|
262
|
+
'X-Content-Type-Options': 'nosniff',
|
|
263
|
+
'Cache-Control': 'no-store',
|
|
264
|
+
'Content-Type': route.mime,
|
|
265
|
+
'Content-Length': String(info.size),
|
|
266
|
+
};
|
|
267
|
+
if (method === 'HEAD') {
|
|
268
|
+
res.writeHead(200, headers);
|
|
269
|
+
res.end();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
res.writeHead(200, headers);
|
|
273
|
+
const stream = createReadStream(real);
|
|
274
|
+
stream.on('error', () => {
|
|
275
|
+
if (!res.writableEnded) res.destroy();
|
|
276
|
+
});
|
|
277
|
+
stream.pipe(res);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (!res.headersSent) send(res, method, 500, faultText(error, `generation ${generation.id} ${route.relative}`));
|
|
280
|
+
else res.destroy();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function handleChannel(req, res) {
|
|
285
|
+
res.writeHead(200, {
|
|
286
|
+
'X-Content-Type-Options': 'nosniff',
|
|
287
|
+
'Cache-Control': 'no-store',
|
|
288
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
289
|
+
Connection: 'keep-alive',
|
|
290
|
+
});
|
|
291
|
+
res.write('retry: 1000\n\n');
|
|
292
|
+
res.write(frame('hello', helloPayload()));
|
|
293
|
+
connections.add(res);
|
|
294
|
+
res.on('close', () => {
|
|
295
|
+
connections.delete(res);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const server = createServer((req, res) => {
|
|
300
|
+
handle(req, res).catch((error) => {
|
|
301
|
+
if (!res.headersSent) send(res, req.method, 500, faultText(error, `${req.method} ${req.url ?? '?'}`));
|
|
302
|
+
else res.destroy();
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
async function handle(req, res) {
|
|
307
|
+
const method = req.method ?? 'GET';
|
|
308
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
309
|
+
send(res, method, 405, 'method not allowed\n', { Allow: 'GET, HEAD' });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const route = resolveRequest(req.url ?? '/');
|
|
313
|
+
if (route.kind === 'channel') {
|
|
314
|
+
if (method === 'HEAD') {
|
|
315
|
+
send(res, method, 405, 'method not allowed\n', { Allow: 'GET' });
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
await handleChannel(req, res);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (route.kind === 'page') {
|
|
322
|
+
const current = generations.current();
|
|
323
|
+
if (current === null) {
|
|
324
|
+
send(res, method, 200, errorPageHtml(latestError()), { 'Content-Type': 'text/html; charset=utf-8' });
|
|
325
|
+
} else {
|
|
326
|
+
send(res, method, 200, pageHtml, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (route.kind === 'config') {
|
|
331
|
+
const current = generations.current();
|
|
332
|
+
if (current === null) {
|
|
333
|
+
send(res, method, 404, 'no successful generation yet\n');
|
|
334
|
+
} else {
|
|
335
|
+
send(res, method, 200, current.configBody, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
336
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (route.kind === 'health') {
|
|
340
|
+
send(res, method, 200, '{"status":"ok","stage":"dev"}\n', { 'Content-Type': 'application/json; charset=utf-8' });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (route.kind === 'generation-config') {
|
|
344
|
+
const generation = generations.get(route.generation);
|
|
345
|
+
if (generation === null) {
|
|
346
|
+
send(res, method, 404, 'not found\n');
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
generations.retain(generation.id);
|
|
350
|
+
generations.touch(generation.id);
|
|
351
|
+
res.on('finish', () => void generations.release(generation.id));
|
|
352
|
+
res.on('close', () => void generations.release(generation.id));
|
|
353
|
+
send(res, method, 200, generation.configBody, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (route.kind === 'generation-asset') {
|
|
357
|
+
const generation = generations.get(route.generation);
|
|
358
|
+
if (generation === null) {
|
|
359
|
+
send(res, method, 404, 'not found\n');
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
await serveGenerationFile(res, method, generation, route);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (route.kind === 'error') {
|
|
366
|
+
send(res, method, route.status, `${route.code}: ${route.message}\n`);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// The runtime mount answers by exact map lookup when installed (no
|
|
371
|
+
// directory to traverse) and by directory containment otherwise.
|
|
372
|
+
if (route.mount === 'runtime' && roots.runtime.kind === 'map') {
|
|
373
|
+
const source = roots.runtime.map.get(route.relative);
|
|
374
|
+
if (source === undefined) {
|
|
375
|
+
send(res, method, 404, 'not found\n');
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const info = await stat(source);
|
|
379
|
+
const headers = {
|
|
380
|
+
'X-Content-Type-Options': 'nosniff',
|
|
381
|
+
'Cache-Control': 'no-store',
|
|
382
|
+
'Content-Type': route.mime,
|
|
383
|
+
'Content-Length': String(info.size),
|
|
384
|
+
};
|
|
385
|
+
if (method === 'HEAD') {
|
|
386
|
+
res.writeHead(200, headers);
|
|
387
|
+
res.end();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
res.writeHead(200, headers);
|
|
391
|
+
const stream = createReadStream(source);
|
|
392
|
+
stream.on('error', () => {
|
|
393
|
+
if (!res.writableEnded) res.destroy();
|
|
394
|
+
});
|
|
395
|
+
stream.pipe(res);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const root = containmentRoot(route.mount);
|
|
400
|
+
if (root === undefined) {
|
|
401
|
+
send(res, method, 404, 'not found\n');
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const real = await inside(root, route.segments);
|
|
405
|
+
if (real === null) {
|
|
406
|
+
send(res, method, 404, 'not found\n');
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const info = await stat(real);
|
|
410
|
+
if (!info.isFile()) {
|
|
411
|
+
send(res, method, 404, 'not found\n');
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (route.mime === null) {
|
|
415
|
+
send(res, method, 415, `unsupported_media_type: the demo has no MIME mapping for ${route.relative}\n`);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const headers = {
|
|
419
|
+
'X-Content-Type-Options': 'nosniff',
|
|
420
|
+
'Cache-Control': 'no-store',
|
|
421
|
+
'Content-Type': route.mime,
|
|
422
|
+
'Content-Length': String(info.size),
|
|
423
|
+
};
|
|
424
|
+
if (method === 'HEAD') {
|
|
425
|
+
res.writeHead(200, headers);
|
|
426
|
+
res.end();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
res.writeHead(200, headers);
|
|
430
|
+
const stream = createReadStream(real);
|
|
431
|
+
stream.on('error', () => {
|
|
432
|
+
if (!res.writableEnded) res.destroy();
|
|
433
|
+
});
|
|
434
|
+
stream.pipe(res);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
439
|
+
const onError = (error) => {
|
|
440
|
+
server.off('listening', onListening);
|
|
441
|
+
rejectListen(error);
|
|
442
|
+
};
|
|
443
|
+
const onListening = () => {
|
|
444
|
+
server.off('error', onError);
|
|
445
|
+
resolveListen();
|
|
446
|
+
};
|
|
447
|
+
server.once('error', onError);
|
|
448
|
+
server.once('listening', onListening);
|
|
449
|
+
server.listen(port, host);
|
|
450
|
+
});
|
|
451
|
+
} catch (error) {
|
|
452
|
+
if (error.code === 'EADDRINUSE') {
|
|
453
|
+
throw serverError(
|
|
454
|
+
'port_in_use',
|
|
455
|
+
`port ${port} on ${host} is already in use`,
|
|
456
|
+
'Retry with --port 0 (the default) to choose a free port, or pass another --port.',
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
if (error.code === 'EACCES') {
|
|
460
|
+
throw serverError('port_denied', `the operating system refused port ${port} on ${host}`, 'Retry with --port 0 to choose an unprivileged free port.');
|
|
461
|
+
}
|
|
462
|
+
throw serverError('server_bind', `could not bind ${host}:${port}: ${error.message}`, 'Retry with --port 0, or inspect the address family and permissions.');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const address = server.address();
|
|
466
|
+
const actualPort = typeof address === 'object' && address !== null ? address.port : port;
|
|
467
|
+
let closed = false;
|
|
468
|
+
async function close() {
|
|
469
|
+
if (closed) return;
|
|
470
|
+
closed = true;
|
|
471
|
+
for (const res of connections) {
|
|
472
|
+
if (!res.destroyed) res.end();
|
|
473
|
+
}
|
|
474
|
+
connections.clear();
|
|
475
|
+
const done = new Promise((resolveClose) => server.close(resolveClose));
|
|
476
|
+
server.closeAllConnections?.();
|
|
477
|
+
await done;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return { url: `http://${host}:${actualPort}/`, host, port: actualPort, server, close, channel };
|
|
481
|
+
}
|