fb-slides 0.7.0 → 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 +24 -0
- package/README.md +60 -5
- package/bin/fb-slides.mjs +7 -0
- package/docs/framework-demos.md +20 -0
- package/docs/source-code.md +15 -0
- package/lib/config.mjs +15 -0
- package/lib/dev.mjs +29 -4
- package/lib/kill.mjs +221 -0
- package/lib/server.mjs +1 -1
- package/package.json +1 -1
- package/templates/starter/_package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ 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
|
+
## 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
|
+
|
|
10
34
|
## 0.7.0 — 2026-09-07
|
|
11
35
|
|
|
12
36
|
- **A `source ↗` button on demo slides.** `editor: true` in `slides.config.js`
|
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.
|
|
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
|
|
318
|
-
`code`
|
|
319
|
-
`source ↗` button
|
|
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)));
|
package/docs/framework-demos.md
CHANGED
|
@@ -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
|
package/docs/source-code.md
CHANGED
|
@@ -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
|
|
@@ -153,6 +159,15 @@ editor: { command: '/usr/local/bin/code' },
|
|
|
153
159
|
table above is read from `servers:` as written in the config, so it still
|
|
154
160
|
works when none of those servers were started.
|
|
155
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
|
+
|
|
156
171
|
## It is your real filesystem
|
|
157
172
|
|
|
158
173
|
The editor opens the actual folder, with write access, as an editor does. Saving
|
package/lib/config.mjs
CHANGED
|
@@ -29,6 +29,10 @@ export const ALWAYS_EXCLUDED = ['node_modules', '.git', '.angular', '.next', '.c
|
|
|
29
29
|
// loses. Two decks at once are handled by walking up from here instead.
|
|
30
30
|
const EDITOR_PORT = 7100;
|
|
31
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
|
+
|
|
32
36
|
// `editor: true` turns the source button on with the defaults; `{ port, command }`
|
|
33
37
|
// changes them; leaving the key out is no button at all. The command is a name in
|
|
34
38
|
// PATH — `code-insiders` and `cursor` answer `serve-web` too — or a path, for a
|
|
@@ -42,6 +46,17 @@ const editorSpec = (value) => {
|
|
|
42
46
|
};
|
|
43
47
|
};
|
|
44
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
|
+
|
|
45
60
|
export const findConfigFile = (root) => CONFIG_FILES.map((name) => join(root, name)).find(existsSync) ?? null;
|
|
46
61
|
|
|
47
62
|
const importConfig = async (file) => {
|
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
|
-
|
|
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.
|
|
@@ -217,13 +240,15 @@ export const dev = async (config, runtimeDir, reload) => {
|
|
|
217
240
|
|
|
218
241
|
const url = `http://localhost:${config.port}/`;
|
|
219
242
|
const children = [];
|
|
243
|
+
const stale = await leftovers(config.servers);
|
|
220
244
|
for (const spec of config.servers) startSideServer(spec, config.root, children);
|
|
221
245
|
const editor = config.editor ? await startEditor(config.editor, children) : {};
|
|
222
246
|
editorUrl = editor.url ?? null;
|
|
223
247
|
|
|
224
248
|
// The last thing printed, so nothing scrolls over it.
|
|
225
249
|
const summary = [``, ` ${config.title}`, ` → ${url}`];
|
|
226
|
-
if (
|
|
250
|
+
if (stale.length) summary.push(...note(stale));
|
|
251
|
+
if (editor.note) summary.push(...note(editor.note));
|
|
227
252
|
console.log(summary.join('\n') + '\n');
|
|
228
253
|
if (config.open) setTimeout(() => openBrowser(url), 400);
|
|
229
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 —
|
|
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