fb-slides 0.7.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.
package/lib/dev.mjs CHANGED
@@ -11,7 +11,7 @@ import { connect } from 'node:net';
11
11
  import { existsSync } from 'node:fs';
12
12
  import { join, resolve } from 'node:path';
13
13
 
14
- import { assertUsable } from './config.mjs';
14
+ import { EDITOR_PORT_TRIES, assertUsable, portOf } from './config.mjs';
15
15
  import { createEditApi } from './edit.mjs';
16
16
  import { createDeckServer, listen } from './server.mjs';
17
17
  import { renderIndex } from './render.mjs';
@@ -55,6 +55,26 @@ const startSideServer = (spec, root, children) => {
55
55
  });
56
56
  };
57
57
 
58
+ // A port that answers before the deck has started anything is almost always the
59
+ // same demo from a run that did not come down cleanly — `dev` kills what it
60
+ // spawned, but a deck killed outright, or with its terminal window closed, kills
61
+ // nothing. The demo's own dev server then either fails or quietly moves one port
62
+ // along, and the slide embedding the old URL shows yesterday's build: the kind of
63
+ // thing that costs ten minutes to work out, and you find it on stage. Probed
64
+ // before anything is spawned, so whatever answers is certainly not ours.
65
+ const leftovers = async (servers) => {
66
+ const withPorts = servers.map((spec) => ({ spec, port: portOf(spec.url) })).filter((each) => each.port);
67
+ const taken = await Promise.all(withPorts.map((each) => portInUse(each.port)));
68
+ const busy = withPorts.filter((_, i) => taken[i]);
69
+ if (!busy.length) return [];
70
+ return [
71
+ ...busy.map(
72
+ ({ spec, port }) => `⚠ ${port} is already in use — ${spec.name ?? spec.cwd} from an earlier run?`,
73
+ ),
74
+ 'That server is not this deck\'s: `npx fb-slides kill` frees the ports it uses.',
75
+ ];
76
+ };
77
+
58
78
  // ---------------------------------------------------------------------------
59
79
  // The source-code editor. `code serve-web` is VS Code's own web server, shipped
60
80
  // inside the editor the presenter already has: no dependency is added here, and
@@ -86,8 +106,7 @@ const portInUse = (port) =>
86
106
 
87
107
  // A second deck, or anything else already sitting there, moves the editor along
88
108
  // rather than turning it off: nobody types this address — the page is handed it.
89
- const EDITOR_PORT_TRIES = 10;
90
-
109
+ // How far it walks is in config.mjs, because `kill` sweeps the same range.
91
110
  const freePort = async (start) => {
92
111
  for (let port = start; port < start + EDITOR_PORT_TRIES; port += 1) {
93
112
  if (!(await portInUse(port))) return port;
@@ -154,6 +173,10 @@ const startEditor = async (spec, children) => {
154
173
  return { url };
155
174
  };
156
175
 
176
+ // Indented under the deck's own URL: first line at the summary's margin, the
177
+ // rest under it.
178
+ const note = (lines) => lines.map((line, i) => (i ? ` ${line}` : ` ${line}`));
179
+
157
180
  // What the page is built from, read again on every request. A config with a
158
181
  // syntax error in it — the state it is in halfway through an edit — leaves the
159
182
  // last good one standing rather than serving a broken deck.
@@ -200,10 +223,11 @@ export const dev = async (config, runtimeDir, reload) => {
200
223
  const editor = editorUrl ? { url: editorUrl, root: config.root, sources: now.demoSources } : null;
201
224
  return renderIndex(now, runtimeDir, editor ? { editor } : {});
202
225
  },
203
- // Editing a slide in the browser saves it back into its .md — dev only,
204
- // which is what makes the edit button appear at all (the runtime probes
205
- // /api/editable). Build and preview never mount this.
206
- api: createEditApi(config.decksPath),
226
+ // Editing a slide in the browser saves it back into its .md, and dropping
227
+ // a file on the deck puts it in assets/ — dev only, which is what makes the
228
+ // edit button appear at all (the runtime probes /api/editable). Build and
229
+ // preview never mount this.
230
+ api: createEditApi(config.decksPath, { dir: config.assetsDir, path: config.assetsPath }),
207
231
  generated: {
208
232
  [`/${REVEAL_THEME_URL}`]: async () => {
209
233
  const now = await current();
@@ -217,13 +241,15 @@ export const dev = async (config, runtimeDir, reload) => {
217
241
 
218
242
  const url = `http://localhost:${config.port}/`;
219
243
  const children = [];
244
+ const stale = await leftovers(config.servers);
220
245
  for (const spec of config.servers) startSideServer(spec, config.root, children);
221
246
  const editor = config.editor ? await startEditor(config.editor, children) : {};
222
247
  editorUrl = editor.url ?? null;
223
248
 
224
249
  // The last thing printed, so nothing scrolls over it.
225
250
  const summary = [``, ` ${config.title}`, ` → ${url}`];
226
- if (editor.note) summary.push(...editor.note.map((line, i) => (i ? ` ${line}` : ` ${line}`)));
251
+ if (stale.length) summary.push(...note(stale));
252
+ if (editor.note) summary.push(...note(editor.note));
227
253
  console.log(summary.join('\n') + '\n');
228
254
  if (config.open) setTimeout(() => openBrowser(url), 400);
229
255
 
package/lib/edit.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
  // POST /api/slide → { op?, file, index, expected, source }
12
12
  // op 'save' (default) replaces the slide, 'add' inserts a new one right
13
13
  // after it, 'delete' removes it — separator and all.
14
+ // /api/assets → dropping a file into assets/ (assets.mjs)
14
15
  //
15
16
  // The slide boundaries are computed here with the same split deck.js uses, so
16
17
  // the index the browser counted is the index this file finds. The splice
@@ -21,6 +22,8 @@
21
22
  import { readFile, writeFile } from 'node:fs/promises';
22
23
  import { resolve, sep } from 'node:path';
23
24
 
25
+ import { createAssetApi } from './assets.mjs';
26
+
24
27
  // Kept in step with deck.js: front matter comes off first, then slides split on
25
28
  // `---` lines, are trimmed, and empty segments are dropped.
26
29
  const FRONT_MATTER = /^---\r?\n[\s\S]*?\r?\n---\r?\n/;
@@ -78,7 +81,11 @@ const readBody = (req, limit = 1024 * 1024) =>
78
81
  // the request arrives same-origin under a name that is not ours.
79
82
  const LOCAL_HOST = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
80
83
 
81
- export const createEditApi = (decksPath) => {
84
+ // `assets` is `{ dir, path }` — the folder a dropped file lands in. Without it
85
+ // the drawer keeps its editing, and says so in the probe, but drops nothing.
86
+ export const createEditApi = (decksPath, assets = null) => {
87
+ const assetApi = assets ? createAssetApi(assets) : null;
88
+
82
89
  const deckFile = (name) => {
83
90
  if (typeof name !== 'string' || !FILE_NAME.test(name) || name.startsWith('.')) return null;
84
91
  const target = resolve(decksPath, name);
@@ -88,7 +95,13 @@ export const createEditApi = (decksPath) => {
88
95
  return async (req, res, pathname) => {
89
96
  if (!LOCAL_HOST.test(req.headers.host ?? '')) return json(res, 403, { error: 'forbidden' });
90
97
 
91
- if (pathname === '/api/editable') return json(res, 200, { editable: true });
98
+ if (pathname === '/api/editable') {
99
+ return json(res, 200, { editable: true, assets: assetApi ? assets.dir : null });
100
+ }
101
+
102
+ if (pathname === '/api/assets') {
103
+ return assetApi ? assetApi(req, res) : json(res, 404, { error: 'not found' });
104
+ }
92
105
 
93
106
  if (pathname !== '/api/slide') return json(res, 404, { error: 'not found' });
94
107
 
package/lib/kill.mjs ADDED
@@ -0,0 +1,221 @@
1
+ // ---------------------------------------------------------------------------
2
+ // `fb-slides kill` — everything the talk left running, gone.
3
+ //
4
+ // `dev` starts more than itself: a demo's own dev server, `code serve-web`. The
5
+ // handler in dev.mjs kills the children it spawned, but the child is npm and the
6
+ // thing holding the port is npm's child; and when the deck goes down by SIGKILL
7
+ // or by a closed terminal window, no handler runs at all. What is left is a
8
+ // process nobody can see holding a port the next run needs.
9
+ //
10
+ // So this does not chase the process tree — it goes at the ports, which are the
11
+ // one thing already written down. `slides.config.js` declares the deck's port,
12
+ // every demo's `url:`, and the editor's; whatever is listening there is what the
13
+ // talk started, and killing the listener is enough — npm exits on its own once
14
+ // the server under it is gone.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ import { execFile } from 'node:child_process';
18
+ import { promisify } from 'node:util';
19
+
20
+ import { EDITOR_PORT_TRIES, portOf } from './config.mjs';
21
+
22
+ const run = promisify(execFile);
23
+
24
+ // Returns the output, `''` when the tool ran and found nothing (lsof exits 1 on
25
+ // an empty match), and `null` when the tool is not on this machine at all.
26
+ const exec = async (command, args) => {
27
+ try {
28
+ const { stdout } = await run(command, args, { maxBuffer: 8 * 1024 * 1024 });
29
+ return stdout;
30
+ } catch (error) {
31
+ return error.code === 'ENOENT' ? null : (error.stdout ?? '');
32
+ }
33
+ };
34
+
35
+ // Every port the talk can be sitting on, each with the name to print for it.
36
+ // `preview` builds and serves on the port above the deck's; the editor walks up
37
+ // from its own when something holds it (dev.mjs), so the whole walk is fair game.
38
+ const targets = (config) => {
39
+ const found = new Map();
40
+ const add = (port, label) => {
41
+ if (Number.isInteger(port) && port > 0 && port < 65536 && !found.has(port)) found.set(port, label);
42
+ };
43
+
44
+ add(config.port, 'deck');
45
+ add(config.port + 1, 'preview');
46
+ for (const spec of config.servers ?? []) {
47
+ add(portOf(spec.url), spec.name ?? spec.cwd ?? spec.command);
48
+ }
49
+ if (config.editor) {
50
+ for (let i = 0; i < EDITOR_PORT_TRIES; i += 1) add(config.editor.port + i, 'editor');
51
+ }
52
+ return [...found].map(([port, label]) => ({ port, label }));
53
+ };
54
+
55
+ const collect = (into, port, pid) => {
56
+ if (!port || !pid) return;
57
+ if (!into.has(port)) into.set(port, new Set());
58
+ into.get(port).add(pid);
59
+ };
60
+
61
+ // `-F pn` is lsof's machine-readable form: a `p<pid>` line, then an `n<address>`
62
+ // line for each socket that process holds.
63
+ const fromLsof = (text) => {
64
+ const ports = new Map();
65
+ let pid = null;
66
+ for (const line of text.split('\n')) {
67
+ if (line[0] === 'p') pid = Number(line.slice(1));
68
+ else if (line[0] === 'n') collect(ports, Number(line.match(/:(\d+)$/)?.[1]), pid);
69
+ }
70
+ return ports;
71
+ };
72
+
73
+ // `ss -tlnpH`: LISTEN … 0.0.0.0:4000 … users:(("node",pid=123,fd=20))
74
+ const fromSs = (text) => {
75
+ const ports = new Map();
76
+ for (const line of text.split('\n')) {
77
+ const port = Number(line.trim().split(/\s+/)[3]?.match(/:(\d+)$/)?.[1]);
78
+ for (const [, pid] of line.matchAll(/pid=(\d+)/g)) collect(ports, port, Number(pid));
79
+ }
80
+ return ports;
81
+ };
82
+
83
+ // `netstat -ano -p tcp`: TCP 0.0.0.0:4000 0.0.0.0:0 LISTENING 1234
84
+ const fromNetstat = (text) => {
85
+ const ports = new Map();
86
+ for (const line of text.split('\n')) {
87
+ if (!/LISTENING/i.test(line)) continue;
88
+ const fields = line.trim().split(/\s+/);
89
+ collect(ports, Number(fields[1]?.match(/:(\d+)$/)?.[1]), Number(fields[4]));
90
+ }
91
+ return ports;
92
+ };
93
+
94
+ // One call, not one per port: asking the machine what it is listening on is the
95
+ // expensive half, and it answers about everything at once anyway.
96
+ const listening = async () => {
97
+ if (process.platform === 'win32') {
98
+ const netstat = await exec('netstat', ['-ano', '-p', 'tcp']);
99
+ return netstat === null ? null : fromNetstat(netstat);
100
+ }
101
+ const lsof = await exec('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pn']);
102
+ if (lsof !== null) return fromLsof(lsof);
103
+ const ss = await exec('ss', ['-tlnpH']);
104
+ return ss === null ? null : fromSs(ss);
105
+ };
106
+
107
+ const CUT = 56;
108
+
109
+ // What to print next to the pid, so it is obvious this is killing the demo and
110
+ // not something else that happens to want 3000.
111
+ const describe = async (pids) => {
112
+ const names = new Map();
113
+ if (!pids.length) return names;
114
+
115
+ if (process.platform === 'win32') {
116
+ const list = (await exec('tasklist', ['/FO', 'CSV', '/NH'])) ?? '';
117
+ for (const line of list.split('\n')) {
118
+ const [, name, pid] = line.match(/^"([^"]*)","(\d+)"/) ?? [];
119
+ if (pid) names.set(Number(pid), name);
120
+ }
121
+ return names;
122
+ }
123
+
124
+ const ps = (await exec('ps', ['-o', 'pid=,command=', '-p', pids.join(',')])) ?? '';
125
+ for (const line of ps.split('\n')) {
126
+ const [, pid, command] = line.trim().match(/^(\d+)\s+(.*)$/) ?? [];
127
+ if (pid) names.set(Number(pid), command.length > CUT ? `${command.slice(0, CUT - 1)}…` : command);
128
+ }
129
+ return names;
130
+ };
131
+
132
+ const alive = (pid) => {
133
+ try {
134
+ process.kill(pid, 0);
135
+ return true;
136
+ } catch (error) {
137
+ // Someone else's process: still there, just not ours to signal.
138
+ return error.code === 'EPERM';
139
+ }
140
+ };
141
+
142
+ const wait = (ms) => new Promise((done) => setTimeout(done, ms));
143
+
144
+ const signal = (pid, name) => {
145
+ try {
146
+ process.kill(pid, name);
147
+ return null;
148
+ } catch (error) {
149
+ return error.code === 'ESRCH' ? null : error.code;
150
+ }
151
+ };
152
+
153
+ // A range prints as a range: the editor alone is ten of these.
154
+ const ranges = (ports) => {
155
+ const sorted = [...ports].sort((a, b) => a - b);
156
+ const out = [];
157
+ for (const port of sorted) {
158
+ const last = out[out.length - 1];
159
+ if (last && port === last[1] + 1) last[1] = port;
160
+ else out.push([port, port]);
161
+ }
162
+ return out.map(([from, to]) => (from === to ? `${from}` : `${from}–${to}`)).join(', ');
163
+ };
164
+
165
+ export const kill = async (config) => {
166
+ const wanted = targets(config);
167
+ const ports = await listening();
168
+
169
+ if (ports === null) {
170
+ console.log(
171
+ `\n ✗ cannot look up ports: no ${process.platform === 'win32' ? 'netstat' : 'lsof or ss'} on this machine.\n`,
172
+ );
173
+ process.exitCode = 1;
174
+ return [];
175
+ }
176
+
177
+ const seen = new Set([process.pid]);
178
+ const hits = [];
179
+ for (const { port, label } of wanted) {
180
+ for (const pid of ports.get(port) ?? []) {
181
+ if (seen.has(pid)) continue;
182
+ seen.add(pid);
183
+ hits.push({ port, label, pid });
184
+ }
185
+ }
186
+
187
+ if (!hits.length) {
188
+ console.log(`\n nothing to kill — ${ranges(wanted.map((t) => t.port))} are free.\n`);
189
+ return [];
190
+ }
191
+
192
+ const names = await describe(hits.map((hit) => hit.pid));
193
+ const width = Math.max(...hits.map((hit) => hit.label.length));
194
+ console.log('');
195
+ for (const hit of hits) {
196
+ console.log(` ✗ ${String(hit.port).padEnd(5)} ${hit.label.padEnd(width)} ${names.get(hit.pid) ?? ''} (${hit.pid})`);
197
+ }
198
+
199
+ // Asked first, insisted on after: a vite or an `ng serve` given the chance to
200
+ // put the terminal back the way it found it usually takes it.
201
+ const refused = [];
202
+ for (const hit of hits) signal(hit.pid, 'SIGTERM');
203
+ await wait(900);
204
+ for (const hit of hits) {
205
+ if (!alive(hit.pid)) continue;
206
+ const error = signal(hit.pid, 'SIGKILL');
207
+ if (error) refused.push({ ...hit, error });
208
+ }
209
+ await wait(200);
210
+
211
+ const left = hits.filter((hit) => alive(hit.pid));
212
+ if (left.length) {
213
+ console.log(`\n ⚠ still up: ${left.map((hit) => hit.pid).join(', ')} — not this user's to kill?`);
214
+ if (refused.length) console.log(` ${refused.map((hit) => `${hit.pid}: ${hit.error}`).join(', ')}`);
215
+ process.exitCode = 1;
216
+ }
217
+
218
+ const stopped = hits.length - left.length;
219
+ console.log(`\n ${stopped} ${stopped === 1 ? 'process' : 'processes'} stopped.\n`);
220
+ return hits;
221
+ };
package/lib/server.mjs CHANGED
@@ -170,7 +170,7 @@ export const listen = (server, port) =>
170
170
  server.once('error', (error) =>
171
171
  reject(
172
172
  error.code === 'EADDRINUSE'
173
- ? new Error(`port ${port} is already in use — free it, or pass --port <n>`)
173
+ ? new Error(`port ${port} is already in use — \`npx fb-slides kill\`, or pass --port <n>`)
174
174
  : error,
175
175
  ),
176
176
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fb-slides",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Markdown-driven reveal.js decks: live demo embeds, annotation, mermaid, and a zero-config dev server",
6
6
  "keywords": [