tosijs-ui 1.7.0-beta.5 → 1.7.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
@@ -1,5 +1,83 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.0
4
+
5
+ > ### ⚠️ BREAKING CHANGE — `<tosi-code>` moved from ACE to CodeMirror 6
6
+ >
7
+ > This is a **breaking change shipping under a minor version**, deliberately. `<tosi-code>`
8
+ > (and the `<tosi-code>`-backed code panels in live examples / the doc browser) is now a
9
+ > CodeMirror 6 wrapper instead of ACE. The public contract that **survives** is unchanged:
10
+ > `value`, `mode`, the `change` event, `disabled`, and `undo`/`redo`. What is **removed**:
11
+ > the ACE-era **`theme`** and **`options`** props — there is no compatibility shim for them
12
+ > (CodeMirror's theming model is different in kind, so a shim would silently no-op). If you set
13
+ > either, migrate: dark mode is now automatic (driven by `body.darkmode`), and editor styling
14
+ > comes from the `--code-bg` / `--text-color` CSS variables. The `editor` property now exposes
15
+ > a CodeMirror `EditorView` rather than an ACE editor.
16
+ >
17
+ > **Why a minor, not 2.0.0:** the `2.0` name is reserved for the tjs-native tosijs port that
18
+ > follows tosijs 2.0; spending it on an editor swap now would missignal that larger change.
19
+ > The deviation from strict semver is intentional and called out here + in the README. **To
20
+ > defer the change, pin `tosijs-ui@1.6`.** Everything else in 1.7.0 is additive.
21
+
22
+ The headline: the editor became first-class for **tjs** and **WebAssembly**, and doc pages got
23
+ dramatically lighter — a reader loads a page with **neither the tjs transpiler nor CodeMirror on
24
+ first paint**, both arriving only when a code panel is opened to edit.
25
+
26
+ ### CodeMirror 6 editor (replaces ACE)
27
+
28
+ `<tosi-code>` is a CodeMirror 6 wrapper. The heavy CM code (`code-editor-cm.ts`) is a **lazy
29
+ chunk** loaded on first edit — for ESM/bundler consumers a page with no editor bundles none of
30
+ it. (The IIFE build cannot code-split, so the CDN `<script>` path still inlines it.) Modes:
31
+ `javascript`, `typescript`, `tjs`, `ajs`, `css`, `html`, `markdown`. New: `undo()`/`redo()`/
32
+ `canUndo()`/`canRedo()`, `showDiff(on)` (diffs against a captured baseline via `tosi-diff`), and
33
+ automatic dark mode via a `highlight` compartment + a `body.darkmode` observer.
34
+
35
+ ### First-class tjs + inline WebAssembly in live examples
36
+
37
+ `tjs`/`ajs` example blocks get tjs-lang's CodeMirror language + **runtime-value autocomplete**
38
+ (completion resolves the live values of an example's locals). Inline `wasm{}` examples run — the
39
+ WASM kernel actually compiles, guarded in CI so a silent fall-back to JS is caught. tjs-lang
40
+ bumped **0.9.0 → 0.12.0** across the release (memory-storm fix in 0.10.1; import-resolver in
41
+ 0.11.0; VM security-review fixes in 0.12.0).
42
+
43
+ ### Self-contained, transpiler-free example pages
44
+
45
+ Each `tjs` example is transpiled **at build time** and its JS baked into the page as a hidden,
46
+ non-executing `<script type="application/tosi-transpiled">`; the example runs from that bake, so
47
+ no transpiler loads for a reader. The bakes ship per-doc in `docs.json` too, so client-side
48
+ navigation gets them at zero extra first-paint cost. Editing an example drops the bake and loads
49
+ the real transpiler + editor on demand; saving keeps the transpiled JS.
50
+
51
+ ### Live examples can import real npm packages (experimental)
52
+
53
+ With `importResolver` enabled, a live example can `import` any npm package — resolved through a
54
+ same-origin service worker (tjs-lang's import-resolver). Three execution modes are flaggable on
55
+ the fence: `inline` (default), `iframe` (DOM/CSS isolation), `ide` (recognized; iframe path for
56
+ now). Fence grammar is order-free: `` ```ts:ide#demo `` and `` ```ts#demo:ide `` both parse.
57
+
58
+ ### Doc-system & build
59
+
60
+ Pre-rendered, hydrating doc pages (the chrome renders server-side and hydrates in place — no
61
+ opacity gate on first paint). The hydration bundle ESM-splits CodeMirror off the critical path.
62
+ Machine-safety guards for the long-lived dev server: an RSS ceiling, an 8-hour idle exit, and a
63
+ preflight that refuses to build on an already-dying machine (Bun's `Bun.build()` native leak,
64
+ oven-sh/bun#34053, is worked around by shelling out to the `bun build` CLI). A live
65
+ `<tosi-css-var-editor>` on component pages. Nested `<tosi-doc-system>` instances no longer share
66
+ state (each gets its own observable registry key).
67
+
68
+ ### Peers
69
+
70
+ `tosijs` peer floor `^1.7.0` — the 1.7 line is a co-released, lockstep stack, and this is the
71
+ version tosijs-ui is built, tested, and (in the iife) bundled against; we don't verify against
72
+ older tosijs, so the declared floor matches what's actually tested. `tjs-lang` `^0.12.0` — a lazy, optional
73
+ peer (a plain component consumer never pulls it in). `@codemirror/*` are the only hard runtime
74
+ dependencies (a deliberate divergence from the zero-runtime-dep rule; they must share one
75
+ `@codemirror/state` instance).
76
+
77
+ ---
78
+
79
+ _The beta changelogs below are retained for detail; 1.7.0 consolidates betas 1–5 + rc.1. Built on tosijs 1.7.0._
80
+
3
81
  ## 1.7.0-beta.3
4
82
 
5
83
  **Self-contained examples, and CodeMirror + the tjs transpiler are now edit-time only.** A
package/README.md CHANGED
@@ -16,6 +16,16 @@
16
16
 
17
17
  Copyright ©2023-2025 Tonio Loewald
18
18
 
19
+ ## ⚠️ Breaking change in 1.7.0 — `<tosi-code>` (ACE → CodeMirror 6)
20
+
21
+ **1.7.0 is a breaking release shipping under a minor version, deliberately.** `<tosi-code>`
22
+ moved from ACE to CodeMirror 6. `value`, `mode`, the `change` event, `disabled`, and
23
+ `undo`/`redo` are unchanged; the ACE-era **`theme`** and **`options`** props are **removed** with
24
+ no shim. Dark mode is now automatic (`body.darkmode`) and styling comes from `--code-bg` /
25
+ `--text-color`. The `2.0` name is reserved for the tjs-native tosijs port, so this ships as
26
+ 1.7.0 — **pin `tosijs-ui@1.6` to defer.** Full detail and rationale in
27
+ [CHANGELOG.md](./CHANGELOG.md).
28
+
19
29
  ## the tosijs-ui library
20
30
 
21
31
  A set of [web-components](https://developer.mozilla.org/en-US/docs/Web/API/Web_components)
@@ -48,9 +48,10 @@ export declare class CodeEditor extends WebComponent<CodeEditorParts> {
48
48
  * JS and CDN consumers — the audience this component's own docs court — would get the
49
49
  * bare TypeError with no explanation at all.
50
50
  *
51
- * So: say it once, on first access. One line in the console beats a stack trace in a
52
- * library the reader has never opened. Use `undo()`/`redo()`/`canUndo()`/`canRedo()`
53
- * for history — they are the supported surface and they survived the migration.
51
+ * So: one neutral note on first access. `editor` is NOT deprecated — it is the
52
+ * supported CM6 accessor — so this is `console.info` (not `warn`) and leads with that,
53
+ * to help a 1.6→1.7 migrator without scolding correct CM6 use. Use
54
+ * `undo()`/`redo()`/`canUndo()`/`canRedo()` for history — they survived the migration.
54
55
  */
55
56
  get editor(): CmHandle['view'] | undefined;
56
57
  /** @deprecated Removed in 1.7 — CodeMirror themes via `--code-bg`/`--text-color`. */
@@ -187,17 +187,17 @@ export class CodeEditor extends WebComponent {
187
187
  * JS and CDN consumers — the audience this component's own docs court — would get the
188
188
  * bare TypeError with no explanation at all.
189
189
  *
190
- * So: say it once, on first access. One line in the console beats a stack trace in a
191
- * library the reader has never opened. Use `undo()`/`redo()`/`canUndo()`/`canRedo()`
192
- * for history — they are the supported surface and they survived the migration.
190
+ * So: one neutral note on first access. `editor` is NOT deprecated — it is the
191
+ * supported CM6 accessor — so this is `console.info` (not `warn`) and leads with that,
192
+ * to help a 1.6→1.7 migrator without scolding correct CM6 use. Use
193
+ * `undo()`/`redo()`/`canUndo()`/`canRedo()` for history — they survived the migration.
193
194
  */
194
195
  get editor() {
195
196
  if (!warnedEditor) {
196
197
  warnedEditor = true;
197
- console.warn('<tosi-code>.editor is a CodeMirror EditorView as of 1.7 (it was an ACE editor ' +
198
- 'in 1.6) — `editor.session`, `editor.getSession()`, `editor.setOption()` and ' +
199
- '`editor.session.getUndoManager()` no longer exist. Use undo()/redo()/' +
200
- 'canUndo()/canRedo() for history; see the CodeMirror 6 docs for the rest.');
198
+ console.info('<tosi-code>.editor is the CodeMirror 6 EditorView (the supported accessor; it ' +
199
+ 'was an ACE editor in 1.6). ACE-era `editor.session` / `getSession()` / ' +
200
+ '`setOption()` are gone — use undo()/redo()/canUndo()/canRedo() for history.');
201
201
  }
202
202
  return this._handle?.view;
203
203
  }
@@ -146,7 +146,7 @@ interface ProjectLinks {
146
146
 
147
147
  The `tosijs-ui` demo is a complete working example. See:
148
148
 
149
- - `/demo/src/index.ts` - How the doc browser is set up
149
+ - `/tosijs-site.config.ts` - How this repo configures its doc site (`defineSiteConfig` / `tosijs-ui/site`)
150
150
  - `/src/doc-system/site/docs.ts` - The extraction tool (`extractDocs`, exported from `tosijs-ui/site`)
151
151
  - `/src/doc-browser.ts` - The createDocBrowser implementation
152
152
  */
@@ -252,6 +252,9 @@ const testIndicatorStyleSpec = {
252
252
  borderRadius: vars.roundedRadius,
253
253
  },
254
254
  };
255
+ // Monotonic per-page counter so each createDocBrowser() call gets a distinct
256
+ // tosi() registry key (see stateKey below) and two browsers can't share state.
257
+ let docBrowserSeq = 0;
255
258
  export function createDocBrowser(options) {
256
259
  const { docs, context = {}, projectName = '', projectLinks = {}, navSize = 200, minSize = 600, routing = 'query', initialRoute, onRouteChange, navbarLinks, contentElement, } = options;
257
260
  // Memory routing is fully self-contained: it never reads or writes
@@ -320,13 +323,21 @@ export function createDocBrowser(options) {
320
323
  };
321
324
  const docName = filenameFromLocation() || docs[0]?.filename || 'README.md';
322
325
  const currentDoc = docs.find((doc) => doc.filename === docName) || docs[0];
323
- const { app } = tosi({
324
- app: {
326
+ // tosi() registers its top-level keys in a GLOBAL observable registry, so two
327
+ // doc browsers on one page (e.g. a page whose live example embeds a nested
328
+ // <tosi-doc-system>) would BOTH register `app` and share it — the second wins,
329
+ // so the outer browser's nav / currentDoc / edit-source silently read the
330
+ // nested one's doc. Give each instance its own key. (This is the same collision
331
+ // the live-example docs warn about: never register a generic `app`.)
332
+ const stateKey = `docBrowser${docBrowserSeq++}`;
333
+ const state = tosi({
334
+ [stateKey]: {
325
335
  docs,
326
336
  currentDoc,
327
337
  compact: false,
328
338
  },
329
339
  });
340
+ const app = state[stateKey];
330
341
  // Assigned by the hierarchical nav builder (path routing); re-applies current
331
342
  // highlight, test status, search visibility, and auto-open imperatively.
332
343
  // resetOpen=true collapses every section except the current doc's.
@@ -1044,7 +1055,7 @@ export function createDocBrowser(options) {
1044
1055
  },
1045
1056
  }, template(a({
1046
1057
  class: 'doc-link',
1047
- bindCurrent: 'app.currentDoc.filename',
1058
+ bindCurrent: `${stateKey}.currentDoc.filename`,
1048
1059
  bindDocLink: '^.filename',
1049
1060
  bindTestStatus: '^.testStatus',
1050
1061
  onClick(event) {
@@ -0,0 +1,18 @@
1
+ import { Component as WebComponent, ElementCreator } from 'tosijs';
2
+ import { TosiForm } from '../form';
3
+ declare class TosiCssVarEditor extends WebComponent {
4
+ static preferredTagName: string;
5
+ static initAttributes: {
6
+ elementSelector: string;
7
+ targetSelector: string;
8
+ };
9
+ content: () => (HTMLHeadingElement | TosiForm)[];
10
+ private retryTimer?;
11
+ private retries;
12
+ loadVars: () => void;
13
+ update: () => void;
14
+ connectedCallback(): void;
15
+ disconnectedCallback(): void;
16
+ }
17
+ export declare const tosiCssVarEditor: ElementCreator<TosiCssVarEditor>;
18
+ export {};
@@ -0,0 +1,85 @@
1
+ /*
2
+ `<tosi-css-var-editor>` — a doc-system affordance (NOT a general library component).
3
+ Reads the CSS custom properties a component defines in its shadow `<style>` and builds
4
+ a live form to tweak them, applying changes to the matching element(s) on the page. It
5
+ is registered as a side effect of loading the doc-system (see doc-system.ts) so doc pages
6
+ can drop `<tosi-css-var-editor element-selector="tosi-widget">` under an example; it is
7
+ deliberately not exported from `tosijs-ui`.
8
+ */
9
+ import { Component as WebComponent, elements, Color, } from 'tosijs';
10
+ import { tosiForm, tosiField } from '../form';
11
+ const { h2, code } = elements;
12
+ // A value is a color if it's a hex literal or an rgb()/hsl() (with or without alpha)
13
+ // functional notation — the forms getComputedStyle / getPropertyValue return. The old
14
+ // regex (`[\d()a-fA-F]+`) rejected the commas and spaces in `rgb(255, 0, 0)`, so every
15
+ // functional color fell through to a plain text field.
16
+ const COLOR_RE = /^(#[0-9a-f]{3,8}|(?:rgb|hsl)a?\([^)]*\))$/i;
17
+ class TosiCssVarEditor extends WebComponent {
18
+ static preferredTagName = 'tosi-css-var-editor';
19
+ static initAttributes = {
20
+ elementSelector: '',
21
+ targetSelector: '',
22
+ };
23
+ content = () => [
24
+ h2({ part: 'title' }, 'CSS variables'),
25
+ tosiForm({ part: 'variables', changeCallback: this.update }),
26
+ ];
27
+ // The example the editor targets can mount a frame or two after this element does;
28
+ // retry a bounded number of times (≈10s) rather than polling forever, and cancel on
29
+ // disconnect so a removed editor can't keep firing timers against a dead page.
30
+ retryTimer;
31
+ retries = 0;
32
+ loadVars = () => {
33
+ const { title, variables } = this.parts;
34
+ variables.textContent = '';
35
+ if (!this.elementSelector)
36
+ return;
37
+ title.textContent = `CSS variables for ${this.elementSelector}`;
38
+ const element = document.querySelector(this.elementSelector);
39
+ if (!element) {
40
+ if (this.retries++ < 40)
41
+ this.retryTimer = setTimeout(this.loadVars, 250);
42
+ return;
43
+ }
44
+ const styleNode = element.shadowRoot
45
+ ? element.shadowRoot.querySelector('style')
46
+ : document.querySelector(`style#${this.elementSelector}-component`);
47
+ if (!styleNode || !styleNode.textContent)
48
+ return;
49
+ const computedStyle = getComputedStyle(element);
50
+ const cssVars = [
51
+ ...new Set(styleNode.textContent.match(/--[\w-]+/g) ?? []),
52
+ ];
53
+ for (const cssVar of cssVars) {
54
+ let value = computedStyle.getPropertyValue(cssVar).trim();
55
+ const type = COLOR_RE.test(value) ? 'color' : 'string';
56
+ if (type === 'color')
57
+ value = Color.fromCss(value).html;
58
+ variables.append(tosiField(code(cssVar), { key: cssVar, value, type }));
59
+ }
60
+ };
61
+ update = () => {
62
+ const selector = this.targetSelector || this.elementSelector;
63
+ if (!selector)
64
+ return;
65
+ const targets = [...document.querySelectorAll(selector)];
66
+ const { value } = this.parts.variables;
67
+ for (const target of targets) {
68
+ for (const key of Object.keys(value)) {
69
+ target.style.setProperty(key, value[key]);
70
+ }
71
+ }
72
+ };
73
+ connectedCallback() {
74
+ super.connectedCallback();
75
+ this.retries = 0;
76
+ this.loadVars();
77
+ this.parts.variables.addEventListener('change', this.update);
78
+ }
79
+ disconnectedCallback() {
80
+ super.disconnectedCallback();
81
+ if (this.retryTimer)
82
+ clearTimeout(this.retryTimer);
83
+ }
84
+ }
85
+ export const tosiCssVarEditor = TosiCssVarEditor.elementCreator();
@@ -1,4 +1,5 @@
1
1
  import { Component, ElementCreator } from 'tosijs';
2
+ import './css-var-editor';
2
3
  export declare class TosiDocSystem extends Component {
3
4
  static preferredTagName: string;
4
5
  static initAttributes: {
@@ -64,6 +64,9 @@ import { docSystemStyleSpec } from './doc-system-styles';
64
64
  import { icons } from '../icons';
65
65
  import { popMenu } from '../menu';
66
66
  import { i18n, setLocale, initLocalization } from '../localize';
67
+ // Side-effect: register <tosi-css-var-editor> so doc pages can drop the live CSS-var
68
+ // tweaker under an example. Part of the doc-system, NOT re-exported from tosijs-ui.
69
+ import './css-var-editor';
67
70
  const { button, div } = elements;
68
71
  const PREFS_KEY = 'tosi-doc-system-prefs';
69
72
  export class TosiDocSystem extends Component {
@@ -425,3 +428,20 @@ export class TosiDocSystem extends Component {
425
428
  }
426
429
  }
427
430
  export const tosiDocSystem = TosiDocSystem.elementCreator();
431
+ // Register the import-resolver service worker when the build opted in — the config
432
+ // global is injected into the page head by orchestrator's importResolverHead (gated on
433
+ // SiteConfig.importResolver). Dynamic import keeps tjs-lang/import-resolver out of the
434
+ // bundle unless a site actually enables it, and out of a plain component consumer's
435
+ // graph entirely. Experimental; 1.7.0 ships with the flag off. See import-resolver-plan.md.
436
+ function initImportResolver() {
437
+ if (typeof document === 'undefined')
438
+ return;
439
+ const cfg = globalThis
440
+ .__TOSI_IMPORT_RESOLVER;
441
+ if (!cfg)
442
+ return;
443
+ void import('tjs-lang/import-resolver')
444
+ .then(({ registerImportResolver }) => registerImportResolver(cfg))
445
+ .catch((e) => console.warn('import-resolver: registration failed', e));
446
+ }
447
+ initImportResolver();
@@ -15,20 +15,28 @@ const docMarked = new Marked();
15
15
  docMarked.use({
16
16
  renderer: {
17
17
  code(token) {
18
+ // Fence info grammar: `<lang>` optionally with a `:<mode>` and/or `#<id>` in
19
+ // EITHER order — `js`, `css#anchor`, `js:iframe`, `ts:ide#demo`, `ts#demo:ide`.
20
+ // `:mode` (inline | iframe | ide) sets the live example's execution mode; `#id`
21
+ // gives it a stable anchor. `#id` is `[A-Za-z0-9_-]+` and `:mode` is `[a-z]+`, so
22
+ // the two can't overlap — parse each independently, order-free. Both are stripped
23
+ // so the language stays clean (`language-js`) for grouping/highlighting.
18
24
  const info = String(token.lang || '');
19
- const hash = info.indexOf('#');
20
- const id = hash === -1
21
- ? ''
22
- : (info.slice(hash + 1).match(/^[A-Za-z0-9_-]+/)?.[0] ?? '');
25
+ const lang = info.match(/^[a-z]+/)?.[0] ?? '';
26
+ const id = info.match(/#([A-Za-z0-9_-]+)/)?.[1] ?? '';
27
+ const mode = info.match(/:([a-z]+)/)?.[1] ?? '';
23
28
  const bake = currentBakes?.get(token.text);
24
- if (!id && !bake)
29
+ if (!id && !mode && !bake)
25
30
  return false; // default rendering — byte-identical
26
- let html = baseRenderer.code({
27
- ...token,
28
- lang: hash === -1 ? info : info.slice(0, hash),
29
- });
30
- if (id)
31
- html = html.replace(/^<pre>/, `<pre data-example-id="${id}">`);
31
+ let html = baseRenderer.code({ ...token, lang });
32
+ const attrs = [
33
+ id && `data-example-id="${id}"`,
34
+ mode && `data-example-mode="${mode}"`,
35
+ ]
36
+ .filter(Boolean)
37
+ .join(' ');
38
+ if (attrs)
39
+ html = html.replace(/^<pre>/, `<pre ${attrs}>`);
32
40
  if (bake) {
33
41
  // `<` → < prevents a `</script>` inside the JS from breaking the tag;
34
42
  // JSON.parse decodes it unchanged at hydration.
@@ -32,8 +32,14 @@ if (!docsJson) {
32
32
  process.exit(1);
33
33
  }
34
34
  const contextKeys = process.argv.slice(3);
35
+ // When the site enables the import-resolver, the parent passes its prefix so
36
+ // non-context imports validate as dynamic `<prefix><spec>` imports rather than failing.
37
+ const importPrefix = process.env.TOSI_IMPORT_PREFIX || undefined;
35
38
  const corpus = JSON.parse(await Bun.file(docsJson).text());
36
- const { problems, bakes } = await checkExamples(corpus, contextKeys.length ? { contextKeys } : {});
39
+ const { problems, bakes } = await checkExamples(corpus, {
40
+ ...(contextKeys.length ? { contextKeys } : {}),
41
+ ...(importPrefix ? { importPrefix } : {}),
42
+ });
37
43
  // stdout is the channel — the parent parses this. Anything else this process prints
38
44
  // (warnings from the transform, say) goes to stderr so it can't corrupt the payload.
39
45
  // Maps can't JSON-roundtrip, so bakes go over as [filename, [source, {dialect,js}][]][].
@@ -25,6 +25,7 @@ export interface ExampleCheck {
25
25
  */
26
26
  export declare function checkExamples(docs: Doc[], opts?: {
27
27
  contextKeys?: string[];
28
+ importPrefix?: string;
28
29
  }): Promise<ExampleCheck>;
29
30
  /** Format problems for a build log. */
30
31
  export declare function formatExampleProblems(problems: ExampleProblem[]): string;
@@ -66,7 +66,7 @@ export async function checkExamples(docs, opts = {}) {
66
66
  // `test` blocks are conventional JS/TS, transpiled as plain js.
67
67
  const dialect = block.lang === 'test' ? 'js' : block.lang;
68
68
  try {
69
- const rewritten = rewriteImports(block.text, contextKeys);
69
+ const rewritten = rewriteImports(block.text, contextKeys, opts.importPrefix);
70
70
  let js;
71
71
  if (dialect === 'ts') {
72
72
  // Use bun's own transpiler — network-free (the runtime `ts` path
@@ -40,7 +40,7 @@ const DEFAULT_IDLE_HOURS = 8;
40
40
  * - `hj` action commands (`navigate`, `click`, …) exit NON-ZERO on failure, so the
41
41
  * test lane below can trust an exit code instead of racing to a timeout.
42
42
  */
43
- const HALTIJA_PKG = process.env.HALTIJA_VERSION ?? 'haltija@^1.4.0';
43
+ const HALTIJA_PKG = process.env.HALTIJA_VERSION ?? 'haltija@^1.5.0';
44
44
  /**
45
45
  * Resolve the idle-exit timeout to milliseconds (0 = disabled).
46
46
  *
@@ -130,6 +130,9 @@ async function killStrayServer(port) {
130
130
  catch {
131
131
  continue; // already gone
132
132
  }
133
+ // Leave a receipt: reclaiming a port is destructive, so if it ever hits a server
134
+ // the developer actually wanted, the log says exactly which pid/comm on which port.
135
+ console.warn(`↻ reclaimed port ${port}: SIGTERM → pid ${pid} (${comm || 'unknown'})`);
133
136
  // Give it a moment to close the listener, then insist.
134
137
  for (let i = 0; i < 20; i++) {
135
138
  await Bun.sleep(50);
@@ -142,6 +145,7 @@ async function killStrayServer(port) {
142
145
  if (i === 19) {
143
146
  try {
144
147
  process.kill(pid, 'SIGKILL');
148
+ console.warn(` pid ${pid} ignored SIGTERM — sent SIGKILL`);
145
149
  }
146
150
  catch {
147
151
  // gone between the check and the signal — fine