@junoflow/cli 0.3.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +693 -52
  2. package/app-client.js +306 -0
  3. package/bin/juno.js +7 -0
  4. package/eslint.app.config.js +34 -0
  5. package/eslint.flow.config.js +35 -0
  6. package/package.json +12 -4
  7. package/src/api.js +541 -30
  8. package/src/app-dev/build.js +134 -0
  9. package/src/app-dev/harness.js +108 -0
  10. package/src/app-dev/panel.html +336 -0
  11. package/src/app-dev/panel.js +290 -0
  12. package/src/app-dev/server.js +347 -0
  13. package/src/app-dev/stream.js +224 -0
  14. package/src/app-dev/watch.js +47 -0
  15. package/src/app-project.js +133 -0
  16. package/src/authoring-skills.js +43 -0
  17. package/src/commands/agent-skill-install.js +64 -0
  18. package/src/commands/app-check.js +75 -0
  19. package/src/commands/app-deploy.js +102 -0
  20. package/src/commands/app-dev.js +141 -0
  21. package/src/commands/app-gen-types.js +37 -0
  22. package/src/commands/app-init.js +46 -0
  23. package/src/commands/app-pull.js +106 -0
  24. package/src/commands/ask.js +60 -0
  25. package/src/commands/check.js +71 -27
  26. package/src/commands/config.js +26 -0
  27. package/src/commands/credential-set.js +68 -0
  28. package/src/commands/deploy.js +95 -51
  29. package/src/commands/flow-check.js +75 -0
  30. package/src/commands/flow-deploy.js +110 -0
  31. package/src/commands/flow-fixture.js +67 -0
  32. package/src/commands/flow-gen-types.js +37 -0
  33. package/src/commands/flow-init.js +59 -0
  34. package/src/commands/flow-pull.js +115 -0
  35. package/src/commands/{types.js → gen-types.js} +12 -5
  36. package/src/commands/init.js +28 -16
  37. package/src/commands/pull.js +111 -63
  38. package/src/commands/session-approve.js +73 -0
  39. package/src/commands/skill.js +73 -0
  40. package/src/commands/tool.js +249 -0
  41. package/src/commands/verify-types.js +4 -2
  42. package/src/config.js +34 -0
  43. package/src/diagnostics.js +14 -0
  44. package/src/errors.js +40 -0
  45. package/src/exit.js +22 -0
  46. package/src/flow-project.js +78 -0
  47. package/src/help.js +199 -0
  48. package/src/index.js +567 -75
  49. package/src/installed-skill.js +55 -0
  50. package/src/project.js +76 -8
  51. package/src/prompt.js +196 -0
  52. package/src/stamp.js +10 -9
  53. package/src/suggest.js +67 -0
  54. package/src/target.js +22 -7
  55. package/src/verbs.js +34 -0
  56. package/src/watch.js +87 -0
  57. package/src/working-copy.js +262 -0
  58. package/tsconfig.page.json +15 -0
  59. package/verbs.json +276 -0
@@ -0,0 +1,290 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ import { readManifestFile } from '../working-copy.js';
5
+ import { openStream, PING_MS } from './stream.js';
6
+
7
+ /** The panel page, served as written (one static file, no framework). */
8
+ const PANEL = readFile(new URL('./panel.html', import.meta.url));
9
+
10
+ /** Where the panel's scenario picker looks. */
11
+ const FIXTURES = 'test/fixtures';
12
+
13
+ /**
14
+ * createPanel is the dev panel's side of `juno app dev` (plan-local-dev-6
15
+ * decisions 7–9): the shell page at `/`, its channel under `/_dev/`, and
16
+ * what a save does.
17
+ *
18
+ * | Request | Body | Answer |
19
+ * | --------------------- | ---------------------------- | ----------------------- |
20
+ * | `GET /` | | panel.html |
21
+ * | `GET /_dev/events` | | SSE: `state`, `reload` |
22
+ * | `POST /_dev/set` | `{states?, controls?}` | 200 `{values}` |
23
+ * | `POST /_dev/press` | `{binding}` | 200 `{binding, at}` |
24
+ * | `POST /_dev/scenario` | `{path}`: a listed one, null | 200 the `state` data |
25
+ *
26
+ * A POST must say `Content-Type: application/json`, which no page on another
27
+ * origin can send without a preflight this server never answers; a body the
28
+ * panel could not have sent is a 400 `{error}`.
29
+ *
30
+ * `state` carries everything the panel draws — `{id, scenario, scenarios,
31
+ * bindings, values, errors}` — and is sent on connect and after anything in
32
+ * it moves. `reload` tells the panel to reload the page's frame.
33
+ *
34
+ * Saves (watchApp's handlers): `src/` rebuilds the handlers at once, so a
35
+ * build error reaches the panel without a call, and reloads only after a
36
+ * good build — the last good one keeps serving. `page/` and `public/`
37
+ * reload. `manifest.json`, when its text changed, rebuilds the harness,
38
+ * keeps the overrides still declared, and reloads. A save under
39
+ * `test/fixtures/` relists the scenarios, and re-applies the one in use,
40
+ * from scratch, when its file is among them.
41
+ */
42
+ export function createPanel({
43
+ cwd,
44
+ dev,
45
+ builder,
46
+ live,
47
+ pingMs = PING_MS,
48
+ log = () => {},
49
+ }) {
50
+ const clients = new Set();
51
+ // What failed: the handlers build is builder.error; these are the others.
52
+ const problems = { manifest: null, scenario: null };
53
+ let manifestRaw = JSON.stringify(dev.manifest);
54
+ let scenarioRaw = null;
55
+ // Saves are applied one at a time, in order.
56
+ let queue = Promise.resolve();
57
+
58
+ const panel = {
59
+ /** The shell: the panel and the page's frame. */
60
+ async shell(req, res) {
61
+ const body = await PANEL;
62
+ res.writeHead(200, {
63
+ 'Content-Type': 'text/html; charset=utf-8',
64
+ 'Content-Length': body.length,
65
+ });
66
+ res.end(req.method === 'HEAD' ? undefined : body);
67
+ },
68
+
69
+ /** Everything under /_dev/. */
70
+ async route(req, res, url) {
71
+ const name = url.pathname.slice('/_dev/'.length);
72
+ if (name === 'events') {
73
+ if (req.method !== 'GET') return methodNotAllowed(res, 'GET');
74
+ const client = openStream(res, pingMs, () => clients.delete(client));
75
+ clients.add(client);
76
+ client.send('state', await state());
77
+ return;
78
+ }
79
+ const action = Object.hasOwn(ACTIONS, name) ? ACTIONS[name] : null;
80
+ if (!action) return sendJSON(res, 404, { error: 'not found' });
81
+ if (req.method !== 'POST') return methodNotAllowed(res, 'POST');
82
+ if (!/^application\/json\b/i.test(req.headers['content-type'] ?? '')) {
83
+ return sendJSON(res, 415, {
84
+ error: 'the panel posts Content-Type: application/json',
85
+ });
86
+ }
87
+ let body;
88
+ try {
89
+ body = JSON.parse((await readBody(req)) || '{}');
90
+ } catch (err) {
91
+ return sendJSON(res, 400, {
92
+ error: `body is not valid JSON: ${err.message}`,
93
+ });
94
+ }
95
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
96
+ return sendJSON(res, 400, { error: 'body must be a JSON object' });
97
+ }
98
+ let answer;
99
+ try {
100
+ answer = await action(body);
101
+ } catch (err) {
102
+ return sendJSON(res, 400, { error: err.message });
103
+ }
104
+ sendJSON(res, 200, answer);
105
+ },
106
+
107
+ /** Sends every panel the current state. */
108
+ async changed() {
109
+ if (clients.size === 0) return;
110
+ broadcast('state', await state());
111
+ },
112
+
113
+ /** watchApp's handlers. */
114
+ watch: {
115
+ src: () => serially(onSource),
116
+ page: () => serially(async () => broadcast('reload', {})),
117
+ manifest: () => serially(onManifest),
118
+ fixtures: () => serially(onFixtures),
119
+ },
120
+
121
+ /** Resolves once every save so far has been applied. */
122
+ settled() {
123
+ return queue;
124
+ },
125
+
126
+ /** Records the scenario file in use, so an unchanged save is ignored. */
127
+ async noteScenario() {
128
+ scenarioRaw = dev.scenario
129
+ ? await readFile(join(cwd, dev.scenario.path), 'utf8').catch(() => null)
130
+ : null;
131
+ },
132
+
133
+ /** Ends every panel connection. */
134
+ close() {
135
+ for (const c of clients) c.end();
136
+ },
137
+ };
138
+
139
+ const ACTIONS = {
140
+ async set(body) {
141
+ live.set({ states: body.states, controls: body.controls });
142
+ await panel.changed();
143
+ return { values: live.values() };
144
+ },
145
+ async press(body) {
146
+ return live.press(body.binding);
147
+ },
148
+ async scenario(body) {
149
+ const path = body.path ?? null;
150
+ if (path !== null && !(await listScenarios()).includes(path)) {
151
+ throw new Error(
152
+ `${JSON.stringify(path)} is not a scenario under ${FIXTURES}/`
153
+ );
154
+ }
155
+ await switchScenario(path);
156
+ return state();
157
+ },
158
+ };
159
+
160
+ /** Applies the scenario at path to a fresh harness; drops the overrides. */
161
+ async function switchScenario(path) {
162
+ await dev.useScenario(path);
163
+ problems.scenario = null;
164
+ await panel.noteScenario();
165
+ live.reset({ clear: true });
166
+ await panel.changed();
167
+ }
168
+
169
+ async function onSource() {
170
+ builder.invalidate();
171
+ try {
172
+ await builder.handlers();
173
+ } catch {
174
+ // Nothing has built yet: builder.error says why.
175
+ }
176
+ if (builder.error) {
177
+ log(`build src/app.ts: ${builder.error.message}`);
178
+ return panel.changed();
179
+ }
180
+ await panel.changed();
181
+ broadcast('reload', {});
182
+ }
183
+
184
+ async function onManifest() {
185
+ let read;
186
+ try {
187
+ read = await readManifestFile(cwd);
188
+ } catch (err) {
189
+ problems.manifest = err.message;
190
+ return panel.changed();
191
+ }
192
+ problems.manifest = null;
193
+ const raw = JSON.stringify(read.manifest);
194
+ if (raw === manifestRaw) return panel.changed();
195
+ manifestRaw = raw;
196
+ dev.setManifest(read.manifest);
197
+ live.reset({ clear: false });
198
+ await panel.changed();
199
+ broadcast('reload', {});
200
+ }
201
+
202
+ async function onFixtures() {
203
+ const path = dev.scenario?.path;
204
+ if (path && (await listScenarios()).includes(path)) {
205
+ const raw = await readFile(join(cwd, path), 'utf8').catch(() => null);
206
+ if (raw !== scenarioRaw) {
207
+ try {
208
+ await switchScenario(path);
209
+ return;
210
+ } catch (err) {
211
+ // The scenario in use stays, and says why on the panel.
212
+ problems.scenario = err.message;
213
+ scenarioRaw = raw;
214
+ }
215
+ }
216
+ }
217
+ await panel.changed();
218
+ }
219
+
220
+ function serially(fn) {
221
+ queue = queue.then(fn).catch((err) => log(`app dev: ${err.message}`));
222
+ return queue;
223
+ }
224
+
225
+ async function listScenarios() {
226
+ let names;
227
+ try {
228
+ names = await readdir(join(cwd, FIXTURES));
229
+ } catch {
230
+ return [];
231
+ }
232
+ return names
233
+ .filter((n) => n.endsWith('.json'))
234
+ .sort()
235
+ .map((n) => `${FIXTURES}/${n}`);
236
+ }
237
+
238
+ async function state() {
239
+ const states = dev.manifest.states ?? {};
240
+ return {
241
+ id: dev.manifest.id,
242
+ scenario: dev.scenario?.path ?? null,
243
+ scenarios: await listScenarios(),
244
+ bindings: {
245
+ states: Object.keys(states)
246
+ .sort()
247
+ .map((name) => ({ name, kind: bindingKind(states[name]) })),
248
+ controls: Object.keys(dev.manifest.controls ?? {}).sort(),
249
+ },
250
+ values: live.values(),
251
+ errors: [
252
+ builder.error && `src/app.ts: ${builder.error.message}`,
253
+ problems.manifest,
254
+ problems.scenario,
255
+ ].filter(Boolean),
256
+ };
257
+ }
258
+
259
+ function broadcast(event, data) {
260
+ for (const c of clients) c.send(event, data);
261
+ }
262
+
263
+ return panel;
264
+ }
265
+
266
+ /** A state binding's kind, as the harness names it. */
267
+ function bindingKind(b) {
268
+ if (b?.slot) return 'slot';
269
+ if (b?.signal) return 'signal';
270
+ return b?.access === 'read_write' ? 'read_write' : 'read';
271
+ }
272
+
273
+ function methodNotAllowed(res, allow) {
274
+ res.setHeader('Allow', allow);
275
+ sendJSON(res, 405, { error: 'method not allowed' });
276
+ }
277
+
278
+ function sendJSON(res, status, body) {
279
+ res.writeHead(status, { 'Content-Type': 'application/json' });
280
+ res.end(JSON.stringify(body));
281
+ }
282
+
283
+ function readBody(req) {
284
+ return new Promise((resolve, reject) => {
285
+ const chunks = [];
286
+ req.on('data', (c) => chunks.push(c));
287
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
288
+ req.on('error', reject);
289
+ });
290
+ }
@@ -0,0 +1,347 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { createServer } from 'node:http';
3
+ import { extname, join, posix } from 'node:path';
4
+
5
+ /**
6
+ * The URL segment the instance answers itself under /<id>/
7
+ * (internal/appserve's reservedSegment); ingest refuses an asset under it, so
8
+ * it never shadows a file here either.
9
+ */
10
+ const RESERVED = '_juno';
11
+
12
+ /**
13
+ * The version every response names. The dev server has one build of the app
14
+ * and never a second, so the client's skew check (`X-Juno-App-Version`
15
+ * against the stamp) never fires.
16
+ */
17
+ export const DEV_VERSION = 1;
18
+
19
+ /** The client the instance serves, byte for byte (TestAppClientCLICopy). */
20
+ const CLIENT = readFile(new URL('../../app-client.js', import.meta.url));
21
+
22
+ /**
23
+ * Content types by extension, for public/. The instance uses Go's
24
+ * mime.TypeByExtension; Node has no table of its own, so this is the part of
25
+ * it a page plausibly serves. Anything else is application/octet-stream.
26
+ */
27
+ const CONTENT_TYPES = {
28
+ '.avif': 'image/avif',
29
+ '.css': 'text/css; charset=utf-8',
30
+ '.gif': 'image/gif',
31
+ '.htm': 'text/html; charset=utf-8',
32
+ '.html': 'text/html; charset=utf-8',
33
+ '.ico': 'image/vnd.microsoft.icon',
34
+ '.jpeg': 'image/jpeg',
35
+ '.jpg': 'image/jpeg',
36
+ '.js': 'text/javascript; charset=utf-8',
37
+ '.json': 'application/json',
38
+ '.map': 'application/json',
39
+ '.mjs': 'text/javascript; charset=utf-8',
40
+ '.mp3': 'audio/mpeg',
41
+ '.mp4': 'video/mp4',
42
+ '.pdf': 'application/pdf',
43
+ '.png': 'image/png',
44
+ '.svg': 'image/svg+xml',
45
+ '.txt': 'text/plain; charset=utf-8',
46
+ '.wasm': 'application/wasm',
47
+ '.webm': 'video/webm',
48
+ '.webmanifest': 'application/manifest+json',
49
+ '.webp': 'image/webp',
50
+ '.woff': 'font/woff',
51
+ '.woff2': 'font/woff2',
52
+ '.xml': 'text/xml; charset=utf-8',
53
+ };
54
+
55
+ /**
56
+ * createDevServer is `juno app dev`'s HTTP server: the instance's apps
57
+ * listener (internal/appserve) for one app, answered from the working copy
58
+ * instead of a stored version.
59
+ *
60
+ * | Request | Answer |
61
+ * | ----------------------------- | -------------------------------------------- |
62
+ * | `GET /` | `routes.shell`: the page in a frame |
63
+ * | `/_dev/<rest>` | `routes.dev` |
64
+ * | `GET /<id>` | 301 → `/<id>/` |
65
+ * | `GET /<id>/`, `/<id>/<entry>` | `page/<entry>` as written |
66
+ * | `GET /<id>/<path>.ts` | that page/ module, bundled for the browser |
67
+ * | `GET /<id>/<path>` | `public/<path>` |
68
+ * | `GET /<id>/_juno/app.js` | the stamp line and the client |
69
+ * | `POST /<id>/_juno/call/<h>` | the harness's answer, as the invoker maps it |
70
+ * | `GET /<id>/_juno/stream` | `routes.stream` |
71
+ *
72
+ * `routes` is the extension point: each member is `(req, res, url)` and owns
73
+ * the response. Left out, `shell` frames the page and `dev` and `stream` are
74
+ * 404s. They are copied onto the returned object's `routes`, which is what
75
+ * the server reads per request, so a member set there later takes effect.
76
+ *
77
+ * dev is the harness (createDevHarness), builder the esbuild wrapper
78
+ * (createBuilder); log receives one line per failed call.
79
+ */
80
+ export function createDevServer({
81
+ cwd,
82
+ dev,
83
+ builder,
84
+ routes = {},
85
+ log = () => {},
86
+ }) {
87
+ const srv = {
88
+ routes: Object.assign({ shell: serveShell }, routes),
89
+ server: createServer((req, res) => {
90
+ handle(req, res).catch((err) => {
91
+ log(`${req.method} ${req.url}: ${err.message}`);
92
+ if (!res.headersSent) sendText(res, 500, 'internal error');
93
+ else res.destroy();
94
+ });
95
+ }),
96
+
97
+ /** Listens on 127.0.0.1 only and resolves to the port. */
98
+ listen(port) {
99
+ return new Promise((resolve, reject) => {
100
+ srv.server.once('error', reject);
101
+ srv.server.listen(port, '127.0.0.1', () => {
102
+ srv.server.off('error', reject);
103
+ resolve(srv.server.address().port);
104
+ });
105
+ });
106
+ },
107
+
108
+ /** Stops listening and drops every open connection (a stream included). */
109
+ close() {
110
+ return new Promise((resolve) => {
111
+ srv.server.close(() => resolve());
112
+ srv.server.closeAllConnections();
113
+ });
114
+ },
115
+ };
116
+
117
+ async function handle(req, res) {
118
+ res.setHeader('X-Content-Type-Options', 'nosniff');
119
+ res.setHeader('Cache-Control', 'no-store');
120
+ const url = new URL(req.url, 'http://localhost');
121
+ const id = dev.manifest.id;
122
+
123
+ if (url.pathname === '/') {
124
+ if (!allowGet(req, res)) return;
125
+ return srv.routes.shell(req, res, url);
126
+ }
127
+ if (url.pathname.startsWith('/_dev/')) {
128
+ return route(srv.routes.dev, req, res, url);
129
+ }
130
+ if (url.pathname === `/${id}`) {
131
+ res.writeHead(301, { Location: `/${id}/${url.search}` });
132
+ return res.end();
133
+ }
134
+ if (!url.pathname.startsWith(`/${id}/`)) {
135
+ return sendHTML(res, 404, 'This app is not available');
136
+ }
137
+
138
+ // Only the app's own frame, the shell's, may embed it.
139
+ res.setHeader('Content-Security-Policy', "frame-ancestors 'self'");
140
+ const rest = safePath(url.pathname.slice(id.length + 2));
141
+ if (rest === null) return sendText(res, 404, 'not found');
142
+
143
+ if (rest.startsWith(`${RESERVED}/`)) {
144
+ const after = rest.slice(RESERVED.length + 1);
145
+ if (after === 'app.js') return serveClient(req, res, id);
146
+ if (after === 'stream') return route(srv.routes.stream, req, res, url);
147
+ if (after.startsWith('call/')) {
148
+ return serveCall(req, res, id, after.slice('call/'.length));
149
+ }
150
+ return sendText(res, 404, 'not found');
151
+ }
152
+ if (!allowGet(req, res)) return;
153
+ const entry = dev.manifest.entry || 'index.html';
154
+ if (rest === '' || rest === entry) {
155
+ return serveFile(
156
+ req,
157
+ res,
158
+ join(cwd, 'page', entry),
159
+ CONTENT_TYPES['.html']
160
+ );
161
+ }
162
+ if (rest.endsWith('.ts')) return servePageModule(req, res, rest);
163
+ return serveFile(req, res, join(cwd, 'public', rest), contentType(rest));
164
+ }
165
+
166
+ /** The default `/`: the page in a full-window frame, so it runs at /<id>/. */
167
+ function serveShell(_req, res) {
168
+ const id = escapeHTML(dev.manifest.id);
169
+ sendHTML(
170
+ res,
171
+ 200,
172
+ `<!doctype html>\n<meta charset="utf-8">\n<title>${id} — juno app dev</title>\n` +
173
+ '<style>html,body{margin:0;height:100%}iframe{border:0;width:100%;height:100%}</style>\n' +
174
+ `<iframe src="/${id}/"></iframe>\n`
175
+ );
176
+ }
177
+
178
+ async function serveClient(req, res, id) {
179
+ if (!allowGet(req, res)) return;
180
+ const stamp = JSON.stringify({
181
+ id,
182
+ version: DEV_VERSION,
183
+ base: `/${id}/`,
184
+ });
185
+ const body = Buffer.concat([
186
+ Buffer.from(`window.__JUNO_APP__=${stamp};\n`),
187
+ await CLIENT,
188
+ ]);
189
+ send(req, res, 200, CONTENT_TYPES['.js'], body);
190
+ }
191
+
192
+ /**
193
+ * A page/ module, built on this request. A `<script type="module">` runs
194
+ * whatever arrives with a JavaScript content type, whatever the URL's
195
+ * extension, so the page needs no rewriting (decision 3). A build failure
196
+ * is a 500 carrying esbuild's message.
197
+ */
198
+ async function servePageModule(req, res, rest) {
199
+ const abs = join(cwd, 'page', rest);
200
+ if (!(await isFile(abs))) return sendText(res, 404, 'not found');
201
+ let js;
202
+ try {
203
+ js = await builder.page(abs);
204
+ } catch (err) {
205
+ log(`build page/${rest}: ${err.message}`);
206
+ return sendText(res, 500, err.message);
207
+ }
208
+ send(req, res, 200, CONTENT_TYPES['.js'], Buffer.from(js));
209
+ }
210
+
211
+ async function serveFile(req, res, abs, type) {
212
+ if (!(await isFile(abs))) return sendText(res, 404, 'not found');
213
+ send(req, res, 200, type, await readFile(abs));
214
+ }
215
+
216
+ /**
217
+ * One handler call, in the order internal/apps/invoker.go decides it: the
218
+ * handler (404), then the body (400; an empty one is {}), then the call. A
219
+ * throw, a harness refusal and a build failure are all 500s carrying the
220
+ * message. Not simulated: the timeout, the in-flight cap, the body cap.
221
+ */
222
+ async function serveCall(req, res, id, handler) {
223
+ if (req.method !== 'POST') {
224
+ res.setHeader('Allow', 'POST');
225
+ return sendCall(res, 405, { error: 'handlers are called with POST' });
226
+ }
227
+ const raw = await readBody(req);
228
+ const handlers = dev.manifest.handlers ?? {};
229
+ if (!Object.hasOwn(handlers, handler)) {
230
+ const have = Object.keys(handlers).sort().join(', ');
231
+ return sendCall(res, 404, {
232
+ error: `app ${JSON.stringify(id)} has no handler ${JSON.stringify(handler)} (have: ${have})`,
233
+ });
234
+ }
235
+ let input;
236
+ try {
237
+ input = raw.trim() === '' ? {} : JSON.parse(raw);
238
+ } catch (err) {
239
+ return sendCall(res, 400, {
240
+ error: `body is not valid JSON: ${err.message}`,
241
+ });
242
+ }
243
+ let result;
244
+ try {
245
+ result = await dev.call(handler, input);
246
+ } catch (err) {
247
+ log(`${handler}: ${err.message}`);
248
+ return sendCall(res, 500, { error: err.message });
249
+ }
250
+ sendCall(res, 200, { result });
251
+ }
252
+
253
+ return srv;
254
+ }
255
+
256
+ /** Hands the request to an optional route, or answers 404 without one. */
257
+ function route(fn, req, res, url) {
258
+ if (typeof fn === 'function') return fn(req, res, url);
259
+ return sendText(res, 404, 'not found');
260
+ }
261
+
262
+ /**
263
+ * The path under /<id>/, decoded and normalised, or null for one that
264
+ * escapes it. A trailing slash is kept, so `dir/` names no file.
265
+ */
266
+ function safePath(encoded) {
267
+ let rel;
268
+ try {
269
+ rel = decodeURIComponent(encoded);
270
+ } catch {
271
+ return null;
272
+ }
273
+ if (rel.includes('\0') || rel.includes('\\')) return null;
274
+ if (rel === '') return '';
275
+ const norm = posix.normalize(rel);
276
+ if (norm === '..' || norm.startsWith('../') || norm.startsWith('/')) {
277
+ return null;
278
+ }
279
+ return norm;
280
+ }
281
+
282
+ function contentType(rel) {
283
+ return (
284
+ CONTENT_TYPES[extname(rel).toLowerCase()] ?? 'application/octet-stream'
285
+ );
286
+ }
287
+
288
+ async function isFile(abs) {
289
+ try {
290
+ return (await stat(abs)).isFile();
291
+ } catch {
292
+ return false;
293
+ }
294
+ }
295
+
296
+ function allowGet(req, res) {
297
+ if (req.method === 'GET' || req.method === 'HEAD') return true;
298
+ res.setHeader('Allow', 'GET, HEAD');
299
+ sendText(res, 405, 'method not allowed');
300
+ return false;
301
+ }
302
+
303
+ function readBody(req) {
304
+ return new Promise((resolve, reject) => {
305
+ const chunks = [];
306
+ req.on('data', (c) => chunks.push(c));
307
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
308
+ req.on('error', reject);
309
+ });
310
+ }
311
+
312
+ /** A body for GET, headers only for HEAD. */
313
+ function send(req, res, status, type, body) {
314
+ res.writeHead(status, {
315
+ 'Content-Type': type,
316
+ 'Content-Length': body.length,
317
+ });
318
+ res.end(req.method === 'HEAD' ? undefined : body);
319
+ }
320
+
321
+ function sendCall(res, status, body) {
322
+ res.writeHead(status, {
323
+ 'Content-Type': 'application/json',
324
+ 'X-Juno-App-Version': String(DEV_VERSION),
325
+ });
326
+ res.end(JSON.stringify(body));
327
+ }
328
+
329
+ function sendText(res, status, text) {
330
+ res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
331
+ res.end(text);
332
+ }
333
+
334
+ function sendHTML(res, status, html) {
335
+ res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
336
+ res.end(html);
337
+ }
338
+
339
+ function escapeHTML(s) {
340
+ return String(s).replace(
341
+ /[&<>"']/g,
342
+ (c) =>
343
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[
344
+ c
345
+ ]
346
+ );
347
+ }