fb-slides 0.7.0-rc.1 → 0.8.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/CHANGELOG.md CHANGED
@@ -7,7 +7,31 @@ this — which makes it worth being able to see what arrived.
7
7
  Entries are written as the work lands, under **Unreleased**; the release commit
8
8
  that stamps the version renames that heading to the version and its date.
9
9
 
10
- ## Unreleased
10
+ ## 0.8.0 — 2026-09-07
11
+
12
+ - **`npm run kill` — everything the talk left running, gone.** `dev` starts more
13
+ than itself, and it takes its side processes down with it only when it is asked
14
+ politely: killed outright, or with its terminal window closed, it leaves a
15
+ `vite` or an `ng serve` under the npm that started it and a `code serve-web`,
16
+ all holding the ports the next run wants. `fb-slides kill` clears them. It goes
17
+ by port rather than by process tree, because the ports are the one thing
18
+ already written down: the deck's, `preview`'s one above it, the `url:` of every
19
+ `servers:` entry, and the editor's 7100–7109 — the whole walk `dev` makes when
20
+ something holds the first one. Whatever is listening there is named, asked to
21
+ stop, and made to; the npm above it exits on its own once its server is gone. A
22
+ `servers:` entry with no `url:` is the one thing it cannot reach. New talks get
23
+ the script from `create`; a talk that already exists adds
24
+ `"kill": "fb-slides kill"` to its `scripts`.
25
+
26
+ - **`dev` says when a port is already taken.** Each `servers:` port is probed
27
+ before anything is spawned, so a port that answers before its demo has started
28
+ is a leftover from a run that did not come down cleanly — and it is named under
29
+ the deck's URL, where the demo servers' own banners cannot bury it, instead of
30
+ turning up as a slide showing yesterday's build. The deck's own port, which has
31
+ always failed outright rather than moved, now names the way out in the same
32
+ breath: ``port 4000 is already in use — `npx fb-slides kill` ``.
33
+
34
+ ## 0.7.0 — 2026-09-07
11
35
 
12
36
  - **A `source ↗` button on demo slides.** `editor: true` in `slides.config.js`
13
37
  starts `code serve-web` — the web server built into VS Code — alongside the
@@ -20,8 +44,10 @@ that stamps the version renames that heading to the version and its date.
20
44
  The slide's address and the one in `servers:` need not match to the character:
21
45
  the exact URL is tried first, then the origin, so a demo embedded at `#/` or
22
46
  `/tools` still finds its folder.
23
- The editor's port — the deck's plus 100 — is checked before it starts, because
24
- `serve-web` has no `--strictPort` and two decks are enough to collide. A built
47
+ The editor listens on 7100 — clear of 3000, 4200, 5173 and the rest of the band
48
+ the demos want, since the editor must never land on the app it exists to open —
49
+ and walks up from there if something holds it, because `serve-web` has no
50
+ `--strictPort` and would otherwise half-bind a busy port. A built
25
51
  deck never carries the button: the link is a localhost address and a path on
26
52
  your disk. `--no-editor` turns it off for one run. When the editor cannot
27
53
  start — no `code` in `PATH`, port taken — the deck comes up as usual and says
package/README.md CHANGED
@@ -34,6 +34,7 @@ its `servers:` entry and its slide come out together.
34
34
  | `npm run dev` | serves the deck on :4000 and opens it |
35
35
  | `npm run build` | `dist/` — self-contained, relative URLs, works offline |
36
36
  | `npm run preview` | build, then serve the result |
37
+ | `npm run kill` | stops the deck, the demos and the editor — whatever is left holding their ports |
37
38
 
38
39
  ## Writing
39
40
 
@@ -107,7 +108,18 @@ Vite — copied into `demo/` as it is. It is embedded by **URL**, not by folder
107
108
  **real VS Code** in a new tab — `code serve-web`, the web server built into the editor you
108
109
  already have, started alongside the deck. Nothing is installed and nothing is embedded, and
109
110
  a built deck never carries it. Run `dev` once at your desk first: VS Code downloads its
110
- server half on the very first use. [docs/source-code.md](docs/source-code.md).
111
+ server half on the very first use.
112
+
113
+ > **It needs VS Code on the machine running the deck.** `serve-web` is a subcommand of
114
+ > VS Code's own CLI, so the button exists only where that CLI does: `code`, or
115
+ > `code-insiders`, or a fork that kept it — `editor: { command: 'cursor' }`. Without it
116
+ > nothing breaks and nothing is missing from the talk; `dev` says so under the deck's URL
117
+ > and every demo slide comes up as it always did, minus the button. The same is true when
118
+ > `code` is installed but not on the `PATH` of the shell you start the deck from, which an
119
+ > IDE's built-in terminal manages on its own — name the binary outright then:
120
+ > `editor: { command: '/usr/local/bin/code' }`.
121
+
122
+ [docs/source-code.md](docs/source-code.md).
111
123
 
112
124
  ### Stepped code highlighting
113
125
 
@@ -294,7 +306,7 @@ export default {
294
306
  { name: 'angular demo', cwd: 'demo/app', command: 'npm', args: ['start', '--', '--port', '4200'] },
295
307
  ],
296
308
 
297
- editor: true, // a `source ↗` button on every demo slide
309
+ editor: true, // a `source ↗` button on every demo slide (needs VS Code)
298
310
 
299
311
  port: 4000,
300
312
  open: true,
@@ -314,9 +326,52 @@ Copying a real Angular or Vite app into `demo/` and wiring it up — including t
314
326
  Vite needs so a busy port fails instead of moving:
315
327
  [docs/framework-demos.md](docs/framework-demos.md).
316
328
 
317
- `editor: true` is a side process of the same kind, and fails the same way — a missing
318
- `code` in PATH, a port already taken, and you get a warning and demo slides without their
319
- `source ↗` button: [docs/source-code.md](docs/source-code.md).
329
+ `editor: true` is a side process of the same kind, and fails the same way: no VS Code on
330
+ the machine, or no `code` on the PATH of the shell that started the deck, and you get a
331
+ warning under the deck's URL and demo slides without their `source ↗` button. It listens on
332
+ 7100, clear of the ports the demos want, and walks up from there if something holds it:
333
+ [docs/source-code.md](docs/source-code.md).
334
+
335
+ ### When something survives
336
+
337
+ `dev` starts more than itself, and it takes its side processes down with it — as long as it
338
+ is asked politely. Killed outright, or with its terminal window closed, it leaves them: a
339
+ `vite` or an `ng serve` under the npm that started it, `code serve-web`, all still holding
340
+ the ports the next run wants.
341
+
342
+ ```bash
343
+ npm run kill
344
+ ```
345
+
346
+ It reads the ports out of this file — the deck's, `preview`'s one above it, the `url:` of
347
+ every `servers:` entry, the editor's 7100–7109 — and stops whatever is listening on them,
348
+ naming each one before it goes:
349
+
350
+ ```
351
+ ✗ 4000 deck node …/bin/fb-slides.mjs dev (39190)
352
+ ✗ 4200 angular demo ng serve (39212)
353
+ ✗ 7100 editor …/Visual Studio Code.app/… (32669)
354
+
355
+ 3 processes stopped.
356
+ ```
357
+
358
+ Going by port rather than by process tree is what makes it work at all after the deck is
359
+ gone: the port is the thing already written down, and the process holding it is the one in
360
+ the way. The npm above it exits on its own once its server is stopped.
361
+
362
+ A `servers:` entry with no `url:` is the one thing it cannot reach — that key is how the
363
+ config says where the demo answers.
364
+
365
+ `dev` also looks before it leaps: a port a `servers:` entry is about to use that is *already*
366
+ answering is a leftover from a previous run, and it says so under the deck's URL rather than
367
+ letting you find out from a demo slide showing yesterday's build.
368
+
369
+ ```
370
+ kill test
371
+ → http://localhost:4010/
372
+ ⚠ 4201 is already in use — angular-hello from an earlier run?
373
+ That server is not this deck's: `npx fb-slides kill` frees the ports it uses.
374
+ ```
320
375
 
321
376
  ## Theming
322
377
 
package/bin/fb-slides.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  // fb-slides dev serve it, open it
7
7
  // fb-slides build dist/, self-contained
8
8
  // fb-slides preview build, then serve dist/
9
+ // fb-slides kill stop whatever `dev` left running
9
10
  // ---------------------------------------------------------------------------
10
11
 
11
12
  import { createRequire } from 'node:module';
@@ -38,6 +39,7 @@ const HELP = `
38
39
  [--no-servers] [--no-editor]
39
40
  fb-slides build build dist/ [--out dir] [--watch]
40
41
  fb-slides preview build, then serve the result [--port n]
42
+ fb-slides kill stop the deck, the demos and the editor
41
43
 
42
44
  Run from a folder holding decks/*.md. slides.config.js is optional.
43
45
  `;
@@ -79,6 +81,11 @@ const run = async () => {
79
81
  return void (await dev(config, RUNTIME, reload));
80
82
  }
81
83
 
84
+ if (command === 'kill') {
85
+ const { kill } = await import('../lib/kill.mjs');
86
+ return void (await kill(config));
87
+ }
88
+
82
89
  if (command === 'build') {
83
90
  const { build, buildWatch } = await import('../lib/build.mjs');
84
91
  return void (await (flag('watch') ? buildWatch(config, RUNTIME) : build(config, RUNTIME)));
@@ -158,6 +158,26 @@ only on the machine running the app. A framework demo is for presenting live. If
158
158
  has to stand on its own on the web, point that slide at a deployed URL — the marker takes
159
159
  any `http(s)` address.
160
160
 
161
+ ## When one outlives the deck
162
+
163
+ `dev` kills the processes it spawned, but the process it spawned is npm and the one holding
164
+ 4200 is npm's child — and when the deck itself is killed outright, or its terminal window
165
+ closed, no handler runs at all. What is left is a dev server nobody can see, on the port the
166
+ next run needs.
167
+
168
+ ```bash
169
+ npm run kill
170
+ ```
171
+
172
+ Every port in `servers:` — that is what `url:` is read for — plus the deck's and the
173
+ editor's, and whatever is listening there is stopped. A demo declared without a `url:` is
174
+ the one this cannot reach.
175
+
176
+ And `dev` warns rather than leaving you to it: it probes each `servers:` port before
177
+ spawning anything, so a port that answers *before* its demo has started is reported under
178
+ the deck's URL. Otherwise the demo's own dev server fails, or moves one port along, and the
179
+ slide keeps showing the run you thought you had stopped.
180
+
161
181
  ## Removing one
162
182
 
163
183
  Three things, all yours: the folder, the `servers:` entry, the slide. Delete them and
@@ -8,6 +8,12 @@ is `code serve-web` — the web server built into the VS Code on the machine
8
8
  running the talk — pointed at the demo's folder. The file tree, the search, the
9
9
  terminal, the extensions, the keybindings: the editor, in a tab.
10
10
 
11
+ Which is also the one requirement: **VS Code has to be installed on the machine
12
+ running the deck**, because `serve-web` is a subcommand of its CLI and nothing
13
+ here reimplements it. A fork that kept the CLI does as well —
14
+ `editor: { command: 'cursor' }`. On a machine without one the deck is unchanged
15
+ except for the button, and `dev` says why under its own URL.
16
+
11
17
  ## Turning it on
12
18
 
13
19
  ```js
@@ -17,13 +23,13 @@ export default {
17
23
  };
18
24
  ```
19
25
 
20
- That is the whole configuration. `dev` starts it alongside the deck, on the
21
- deck's port plus 100, and every demo slide grows the link.
26
+ That is the whole configuration. `dev` starts it alongside the deck, on port
27
+ 7100, and every demo slide grows the link.
22
28
 
23
29
  ```
24
30
  $ npm run dev
25
31
 
26
- ↑ code serve-web → http://localhost:4100/
32
+ ↑ code serve-web → http://localhost:7100/
27
33
 
28
34
  My Talk
29
35
  → http://localhost:4000/
@@ -32,10 +38,11 @@ $ npm run dev
32
38
  Both defaults move if they have to:
33
39
 
34
40
  ```js
35
- editor: { port: 5000, command: 'code-insiders' },
41
+ editor: { port: 7300, command: 'code-insiders' },
36
42
  ```
37
43
 
38
- `command` is a name in PATH. `code-insiders` and `cursor` answer `serve-web`
44
+ `command` is a name in PATH, or a path to the binary when the shell you start
45
+ the deck from does not carry it. `code-insiders` and `cursor` answer `serve-web`
39
46
  too; whatever you name has to be a VS Code CLI, because that subcommand is what
40
47
  this uses.
41
48
 
@@ -49,28 +56,30 @@ Start `dev` once at your desk after turning the key on. That is the whole
49
56
  precaution, and it is why `editor:` is off in a fresh deck rather than on.
50
57
 
51
58
  If `code` is not in PATH at all, the deck says so and carries on without the
52
- button:
59
+ button — see [When no slide has one](#when-no-slide-has-one) below.
53
60
 
54
- ```
55
- ⚠ code is not in PATH — the demo slides get no source button.
56
- In VS Code: Shell Command: Install 'code' command in PATH.
57
- ```
61
+ ## Why 7100
58
62
 
59
- ## When the port is taken
63
+ Because the demos own everything below it. 3000 is Next's default, 4200 is
64
+ `ng serve`'s, 5173 and 5174 are Vite's, 6006 is Storybook's, 8080 is everyone's.
65
+ The editor landing on the very app it exists to open is the one collision that
66
+ must not happen — and since `dev` starts the demo servers first, the editor is
67
+ the one that would lose.
60
68
 
61
- The default is the deck's port plus 100, and **two decks are enough to collide**:
62
- one on 4000 puts its editor on 4100, which is a perfectly ordinary port for the
63
- second deck to be on.
69
+ It is not derived from the deck's port for the same reason: a deck on 4100 would
70
+ put its editor on 4200, which is `ng serve` waiting to happen.
64
71
 
65
72
  `code serve-web` does not fail on a busy port the way the deck server does — a
66
73
  process holding the IPv6 side leaves the IPv4 side free, both bind, and
67
74
  `localhost` in the browser then lands on whichever the resolver picked. That is
68
75
  the failure you find out about on stage. So the port is checked before the
69
- editor is started, and a taken one is a message rather than a wrong tab:
76
+ editor starts, and a taken one moves the editor along: 7101, 7102, up to ten
77
+ tries. Nobody types this address — the page is handed it — so a different number
78
+ costs nothing. Only ten taken ports in a row is a message:
70
79
 
71
80
  ```
72
- ⚠ port 4100 is in use — no source button.
73
- Free it, or give the editor another one: `editor: { port: n }`.
81
+ ⚠ source ↗ off: ports 7100–7109 are all in use.
82
+ Give the editor one of its own: `editor: { port: n }`.
74
83
  ```
75
84
 
76
85
  ## Where a slide's code is found
@@ -150,6 +159,15 @@ editor: { command: '/usr/local/bin/code' },
150
159
  table above is read from `servers:` as written in the config, so it still
151
160
  works when none of those servers were started.
152
161
 
162
+ ## When it outlives the deck
163
+
164
+ `serve-web` is a side process, and it goes down with the deck — unless the deck goes down
165
+ the hard way, killed outright or with its terminal window closed. Then it stays up, on
166
+ 7100, invisible, and the next run finds the port taken and walks past it to 7101.
167
+
168
+ `npm run kill` sweeps the whole walk, 7100 through 7109, along with the deck's port and the
169
+ demos'. Worth knowing before wondering why the editor is on a different port every day.
170
+
153
171
  ## It is your real filesystem
154
172
 
155
173
  The editor opens the actual folder, with write access, as an editor does. Saving
package/lib/config.mjs CHANGED
@@ -21,19 +21,42 @@ const AUTO_STATIC = ['assets', 'demo', 'public', 'images', 'img'];
21
21
  // Never served, never published — heavy, private, or generated.
22
22
  export const ALWAYS_EXCLUDED = ['node_modules', '.git', '.angular', '.next', '.cache', '.DS_Store'];
23
23
 
24
+ // The editor's port. A fixed number rather than one derived from the deck's,
25
+ // because the band under 6000 belongs to the demos: 3000 is Next's, 4200 is
26
+ // `ng serve`'s, 5173 and 5174 are Vite's, 8080 is everyone's. An editor that
27
+ // lands on the app it exists to open is the one collision that must not happen
28
+ // — and `dev` starts the demo servers first, so it would be the editor that
29
+ // loses. Two decks at once are handled by walking up from here instead.
30
+ const EDITOR_PORT = 7100;
31
+
32
+ // How far up from there `dev` walks when something already holds it, and so how
33
+ // far `kill` has to sweep to be sure it caught the editor of an earlier run.
34
+ export const EDITOR_PORT_TRIES = 10;
35
+
24
36
  // `editor: true` turns the source button on with the defaults; `{ port, command }`
25
- // changes them; leaving the key out is no button at all. The port follows the
26
- // deck's so two talks open at once do not fight over it, and the command is a
27
- // name in PATH — `code-insiders` and `cursor` answer `serve-web` too.
28
- const editorSpec = (value, deckPort) => {
37
+ // changes them; leaving the key out is no button at all. The command is a name in
38
+ // PATH — `code-insiders` and `cursor` answer `serve-web` too — or a path, for a
39
+ // shell whose PATH does not carry the editor.
40
+ const editorSpec = (value) => {
29
41
  if (!value) return null;
30
42
  const user = value === true ? {} : value;
31
43
  return {
32
44
  command: user.command ?? 'code',
33
- port: Number(user.port ?? deckPort + 100),
45
+ port: Number(user.port ?? EDITOR_PORT),
34
46
  };
35
47
  };
36
48
 
49
+ // The port a `servers:` entry answers on, read out of its `url:`. Both `dev`
50
+ // (is something already sitting there?) and `kill` (what do I stop?) ask this.
51
+ export const portOf = (url) => {
52
+ try {
53
+ const { port, protocol } = new URL(url);
54
+ return Number(port || (protocol === 'https:' ? 443 : 80));
55
+ } catch {
56
+ return null;
57
+ }
58
+ };
59
+
37
60
  export const findConfigFile = (root) => CONFIG_FILES.map((name) => join(root, name)).find(existsSync) ?? null;
38
61
 
39
62
  const importConfig = async (file) => {
@@ -109,7 +132,7 @@ export const loadConfig = async (root = process.cwd(), overrides = {}) => {
109
132
  // `--no-editor` is its own flag rather than a part of `--no-servers`: that one
110
133
  // is for skipping a demo's dev server, and a demo that is not running is
111
134
  // exactly when you want to be able to open its code.
112
- config.editor = overrides.editor === false ? null : editorSpec(user.editor, config.port);
135
+ config.editor = overrides.editor === false ? null : editorSpec(user.editor);
113
136
  // The folder behind each demo embedded by URL, taken from `servers:` as the
114
137
  // project wrote it rather than from the list `dev` will start: `--no-servers`
115
138
  // empties that one, and a demo that is not running is exactly when its code is
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
@@ -66,9 +86,7 @@ const startSideServer = (spec, root, children) => {
66
86
  // `serve-web` has no --strictPort, and it does not fail on a taken port the way
67
87
  // the deck server does: another process holding the IPv6 side leaves the IPv4
68
88
  // side free, both bind, and `localhost` in the browser then lands on whichever
69
- // the resolver picked. Two fb-slides decks are enough to arrange that — one on
70
- // 4000 puts its editor on 4100, which is a perfectly ordinary port for the
71
- // second deck. So ask first, and say which port to use instead.
89
+ // the resolver picked — the failure you find out about on stage. So ask first.
72
90
  const portInUse = (port) =>
73
91
  Promise.all(
74
92
  ['127.0.0.1', '::1'].map(
@@ -86,6 +104,16 @@ const portInUse = (port) =>
86
104
  ),
87
105
  ).then((answers) => answers.some(Boolean));
88
106
 
107
+ // A second deck, or anything else already sitting there, moves the editor along
108
+ // rather than turning it off: nobody types this address — the page is handed it.
109
+ // How far it walks is in config.mjs, because `kill` sweeps the same range.
110
+ const freePort = async (start) => {
111
+ for (let port = start; port < start + EDITOR_PORT_TRIES; port += 1) {
112
+ if (!(await portInUse(port))) return port;
113
+ }
114
+ return null;
115
+ };
116
+
89
117
  const hasCommand = (command) =>
90
118
  new Promise((done) => {
91
119
  const probe = spawn(command, ['--version'], { stdio: 'ignore' });
@@ -108,16 +136,17 @@ const startEditor = async (spec, children) => {
108
136
  };
109
137
  }
110
138
 
111
- if (await portInUse(spec.port)) {
139
+ const port = await freePort(spec.port);
140
+ if (port === null) {
112
141
  return {
113
142
  note: [
114
- `⚠ source ↗ off: port ${spec.port} is in use.`,
115
- `Free it, or give the editor another one: \`editor: { port: n }\`.`,
143
+ `⚠ source ↗ off: ports ${spec.port}–${spec.port + EDITOR_PORT_TRIES - 1} are all in use.`,
144
+ `Give the editor one of its own: \`editor: { port: n }\`.`,
116
145
  ],
117
146
  };
118
147
  }
119
148
 
120
- const url = `http://localhost:${spec.port}/`;
149
+ const url = `http://localhost:${port}/`;
121
150
  // First run downloads the server half of VS Code into ~/.vscode/cli, which is
122
151
  // a hundred megabytes and a minute — hence `stdio: 'inherit'`, so it happens
123
152
  // in front of whoever started the deck rather than silently before a talk.
@@ -127,7 +156,7 @@ const startEditor = async (spec, children) => {
127
156
  [
128
157
  'serve-web',
129
158
  '--port',
130
- String(spec.port),
159
+ String(port),
131
160
  // No token in the URL: the slide has to be able to link straight to a
132
161
  // folder, and this listens on localhost only.
133
162
  '--without-connection-token',
@@ -144,6 +173,10 @@ const startEditor = async (spec, children) => {
144
173
  return { url };
145
174
  };
146
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
+
147
180
  // What the page is built from, read again on every request. A config with a
148
181
  // syntax error in it — the state it is in halfway through an edit — leaves the
149
182
  // last good one standing rather than serving a broken deck.
@@ -207,13 +240,15 @@ export const dev = async (config, runtimeDir, reload) => {
207
240
 
208
241
  const url = `http://localhost:${config.port}/`;
209
242
  const children = [];
243
+ const stale = await leftovers(config.servers);
210
244
  for (const spec of config.servers) startSideServer(spec, config.root, children);
211
245
  const editor = config.editor ? await startEditor(config.editor, children) : {};
212
246
  editorUrl = editor.url ?? null;
213
247
 
214
248
  // The last thing printed, so nothing scrolls over it.
215
249
  const summary = [``, ` ${config.title}`, ` → ${url}`];
216
- if (editor.note) summary.push(...editor.note.map((line, i) => (i ? ` ${line}` : ` ${line}`)));
250
+ if (stale.length) summary.push(...note(stale));
251
+ if (editor.note) summary.push(...note(editor.note));
217
252
  console.log(summary.join('\n') + '\n');
218
253
  if (config.open) setTimeout(() => openBrowser(url), 400);
219
254
 
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-rc.1",
3
+ "version": "0.8.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": [
@@ -6,7 +6,8 @@
6
6
  "scripts": {
7
7
  "dev": "fb-slides dev",
8
8
  "build": "fb-slides build",
9
- "preview": "fb-slides preview"
9
+ "preview": "fb-slides preview",
10
+ "kill": "fb-slides kill"
10
11
  },
11
12
  "devDependencies": {
12
13
  "__PKG__": "__VERSION__"
@@ -44,9 +44,10 @@ export default {
44
44
  // and nothing is embedded. Off by default because the very first run downloads
45
45
  // VS Code's server half (~100 MB) and you do not want to discover that on
46
46
  // stage: uncomment, run `dev` once at your desk, and it is cached from then on.
47
- // `{ port: 4100, command: 'code' }` to change either — the default port is the
48
- // deck's plus 100. `--no-editor` turns it off for one run, and a
49
- // built deck never has it: the link is a localhost address and a path on disk.
47
+ // `{ port: 7300, command: 'code' }` to change either — the default is 7100,
48
+ // which is clear of the ports the demos want. `--no-editor` turns it off for
49
+ // one run, and a built deck never has it: the link is a localhost address and
50
+ // a path on disk.
50
51
  editor: true,
51
52
 
52
53
  // The corner signature. Uncomment to show it.