tosijs-ui 1.6.21 → 1.6.23
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 +97 -0
- package/dist/code-editor.d.ts +9 -35
- package/dist/code-editor.js +62 -90
- package/dist/doc-browser.js +6 -3
- package/dist/doc-system/doc-title.d.ts +19 -0
- package/dist/doc-system/doc-title.js +51 -0
- package/dist/doc-system/site/check-examples-cli.d.ts +1 -0
- package/dist/doc-system/site/check-examples-cli.js +39 -0
- package/dist/doc-system/site/dev-server.d.ts +26 -0
- package/dist/doc-system/site/dev-server.js +444 -19
- package/dist/doc-system/site/epub-cli.d.ts +1 -0
- package/dist/doc-system/site/epub-cli.js +22 -0
- package/dist/doc-system/site/epub.js +6 -0
- package/dist/doc-system/site/generate-site.js +4 -6
- package/dist/doc-system/site/make-llms-txt.js +0 -16
- package/dist/doc-system/site/orchestrator.js +176 -52
- package/dist/doc-system/site/preflight.d.ts +152 -0
- package/dist/doc-system/site/preflight.js +401 -0
- package/dist/doc-system/site/site-config.d.ts +34 -0
- package/dist/iife.js +109 -153
- package/dist/iife.js.map +9 -36
- package/dist/live-example/component.d.ts +0 -1
- package/dist/live-example/component.js +22 -200
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +2 -9
- package/package.json +3 -18
- package/tls/create-dev-certs.sh +9 -25
- package/dist/code-editor-cm.d.ts +0 -48
- package/dist/code-editor-cm.js +0 -219
|
@@ -12,13 +12,142 @@ import { statSync } from 'fs';
|
|
|
12
12
|
import { watch } from 'chokidar';
|
|
13
13
|
import { $, spawn } from 'bun';
|
|
14
14
|
import { buildSite } from './orchestrator';
|
|
15
|
+
import { preflight } from './preflight';
|
|
15
16
|
const TEST_RESULTS_FILE = '.browser-tests.json';
|
|
17
|
+
const DEFAULT_IDLE_HOURS = 8;
|
|
18
|
+
/**
|
|
19
|
+
* The haltija the dev server spawns (`bunx <this>`).
|
|
20
|
+
*
|
|
21
|
+
* Pinned to a **range with a floor**, not `@latest`. This is library code: every
|
|
22
|
+
* adopter's dev server runs whatever this string resolves to, so `@latest` means a
|
|
23
|
+
* floating executable fetch — the tool can change under every consumer overnight,
|
|
24
|
+
* with haltija in nobody's lockfile and no version contract. A caret range still
|
|
25
|
+
* picks up fixes without letting a major land unannounced.
|
|
26
|
+
*
|
|
27
|
+
* Override with `HALTIJA_VERSION` (e.g. `HALTIJA_VERSION=haltija@beta` to test a
|
|
28
|
+
* pre-release, or a local path/tarball) — you should not have to edit library code
|
|
29
|
+
* to try a different haltija.
|
|
30
|
+
*
|
|
31
|
+
* Floor is **1.4.0** — the first release whose behavior this integration can rely on:
|
|
32
|
+
* - `hj` routes to the server owning the current directory, so `hj` inside a project
|
|
33
|
+
* drives THAT project's browser instead of falling back to a shared port and
|
|
34
|
+
* silently driving whoever was focused there.
|
|
35
|
+
* - a haltija server no longer overwrites the machine-wide `hj` binary with an older
|
|
36
|
+
* copy on startup — so OUR spawning one can't downgrade the CLI for an unrelated,
|
|
37
|
+
* up-to-date project (the exact "unpinned executable fetch from library code"
|
|
38
|
+
* hazard this pin exists to bound).
|
|
39
|
+
* - HTTPS-only servers stopped advertising an HTTP port they weren't listening on.
|
|
40
|
+
* - `hj` action commands (`navigate`, `click`, …) exit NON-ZERO on failure, so the
|
|
41
|
+
* test lane below can trust an exit code instead of racing to a timeout.
|
|
42
|
+
*/
|
|
43
|
+
const HALTIJA_PKG = process.env.HALTIJA_VERSION ?? 'haltija@^1.4.0';
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the idle-exit timeout to milliseconds (0 = disabled).
|
|
46
|
+
*
|
|
47
|
+
* Env wins over config, config over the default. An unparseable value falls back
|
|
48
|
+
* to the default rather than to 0: a typo'd `DEV_IDLE_TIMEOUT_HOURS=8h` must not
|
|
49
|
+
* silently turn the guard OFF — that is the exact failure it exists to prevent.
|
|
50
|
+
* Only an explicit non-positive number disables it.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveIdleMs(configHours, envHours) {
|
|
53
|
+
const env = envHours?.trim();
|
|
54
|
+
const raw = env ? Number(env) : configHours ?? DEFAULT_IDLE_HOURS;
|
|
55
|
+
const hours = Number.isFinite(raw) ? raw : DEFAULT_IDLE_HOURS;
|
|
56
|
+
return hours > 0 ? hours * 3600_000 : 0;
|
|
57
|
+
}
|
|
58
|
+
const DEFAULT_LIMIT_MB = 4096;
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the RSS ceiling in MB (0 = disabled).
|
|
61
|
+
*
|
|
62
|
+
* Same rule as `resolveIdleMs`, and it was NOT being applied: the ceiling was read as
|
|
63
|
+
* `Number(env ?? config ?? 4096)`, which fails in both directions at once —
|
|
64
|
+
*
|
|
65
|
+
* DEV_MEMORY_LIMIT_MB='' → `??` passes '' through → Number('') === 0 → the ceiling
|
|
66
|
+
* is ZERO, so `rss >= limit` is true on the first sample
|
|
67
|
+
* and the dev server kills itself on every rebuild.
|
|
68
|
+
* DEV_MEMORY_LIMIT_MB=4gb → NaN → every `>=` comparison is false → the guard is
|
|
69
|
+
* silently OFF, on the machine of someone who was
|
|
70
|
+
* explicitly trying to configure it.
|
|
71
|
+
*
|
|
72
|
+
* An empty env var is *unset*, not zero. Garbage falls back to the default, never to
|
|
73
|
+
* off. Only an explicit non-positive number disables the ceiling.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveLimitMb(configMb, envMb) {
|
|
76
|
+
const env = envMb?.trim();
|
|
77
|
+
const raw = env ? Number(env) : configMb ?? DEFAULT_LIMIT_MB;
|
|
78
|
+
const mb = Number.isFinite(raw) ? raw : DEFAULT_LIMIT_MB;
|
|
79
|
+
return mb > 0 ? mb : 0;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Reclaim the port we are about to bind, from the process LISTENING on it.
|
|
83
|
+
*
|
|
84
|
+
* That sentence is the predicate, and the old code did not implement it. It ran
|
|
85
|
+
* `lsof -ti:${port} | xargs kill -9`, and **`lsof -i:PORT` matches sockets whose
|
|
86
|
+
* LOCAL *or REMOTE* port is PORT** — so it returned every process merely *connected*
|
|
87
|
+
* to that port and SIGKILLed them all. Not theoretical: on this machine
|
|
88
|
+
* `lsof -ti:443` returns GitHub Desktop, Proton Bridge and two `claude` processes,
|
|
89
|
+
* none of which listen on 443. Aimed at our own dev port it would take out the
|
|
90
|
+
* browser reading the page, Playwright's browsers, the haltija Electron — anything
|
|
91
|
+
* with an open connection.
|
|
92
|
+
*
|
|
93
|
+
* We shipped that reasoning in this very file for `pkill -f haltija` ("a test lane
|
|
94
|
+
* that reaches outside the repo and kills the developer's tools presents as 'my tools
|
|
95
|
+
* got weird', never as a red test") and then failed to apply it here.
|
|
96
|
+
*
|
|
97
|
+
* So: listeners only; confirm each pid is actually a JS runtime before signalling
|
|
98
|
+
* (if something else owns our port, that is the user's business — say so, don't shoot
|
|
99
|
+
* it); SIGTERM first and give it a moment; SIGKILL only what refuses to die.
|
|
100
|
+
*/
|
|
16
101
|
async function killStrayServer(port) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
102
|
+
// Number('') is 0, and `lsof -ti:0` matches sockets with an UNBOUND port — on this
|
|
103
|
+
// machine, system daemons. Never let a bad port become a kill list.
|
|
104
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535)
|
|
105
|
+
return;
|
|
106
|
+
const pids = await $ `lsof -ti:${port} -sTCP:LISTEN`
|
|
107
|
+
.quiet()
|
|
108
|
+
.text()
|
|
109
|
+
.then((out) => out
|
|
110
|
+
.trim()
|
|
111
|
+
.split('\n')
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
.map(Number)
|
|
114
|
+
.filter((p) => Number.isInteger(p) && p > 0 && p !== process.pid))
|
|
115
|
+
.catch(() => []); // nothing listening — the normal case
|
|
116
|
+
for (const pid of pids) {
|
|
117
|
+
const comm = await $ `ps -p ${pid} -o comm=`
|
|
118
|
+
.quiet()
|
|
119
|
+
.text()
|
|
120
|
+
.then((s) => s.trim())
|
|
121
|
+
.catch(() => '');
|
|
122
|
+
if (!/\b(bun|node|deno)\b/.test(comm)) {
|
|
123
|
+
console.warn(`⚠️ port ${port} is held by pid ${pid} (${comm || 'unknown'}), which is not a\n` +
|
|
124
|
+
` dev server — leaving it alone. Free the port, or set PORT to another one.`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
process.kill(pid, 'SIGTERM');
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
continue; // already gone
|
|
132
|
+
}
|
|
133
|
+
// Give it a moment to close the listener, then insist.
|
|
134
|
+
for (let i = 0; i < 20; i++) {
|
|
135
|
+
await Bun.sleep(50);
|
|
136
|
+
try {
|
|
137
|
+
process.kill(pid, 0);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
break; // exited
|
|
141
|
+
}
|
|
142
|
+
if (i === 19) {
|
|
143
|
+
try {
|
|
144
|
+
process.kill(pid, 'SIGKILL');
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// gone between the check and the signal — fine
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
22
151
|
}
|
|
23
152
|
}
|
|
24
153
|
/**
|
|
@@ -48,10 +177,8 @@ async function ensureHaltijaChannel(port) {
|
|
|
48
177
|
// HTTPS dev page has no mixed-content), while the `hj` CLI drives over its
|
|
49
178
|
// default HTTP 8700 — one server, both transports, shared state. HTTPS cert
|
|
50
179
|
// is mkcert-trusted. Output quiet so it doesn't drown the dev log.
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
// an accidental Electron launch. (Was pinned to `@beta` before 1.3.2.)
|
|
54
|
-
spawn(['bunx', 'haltija@latest', '--server', '--both'], {
|
|
180
|
+
// Version comes from HALTIJA_PKG — a pinned range, not `@latest`; see above.
|
|
181
|
+
spawn(['bunx', HALTIJA_PKG, '--server', '--both'], {
|
|
55
182
|
stdout: 'ignore',
|
|
56
183
|
stderr: 'ignore',
|
|
57
184
|
});
|
|
@@ -80,17 +207,51 @@ async function ensureDevCerts() {
|
|
|
80
207
|
process.exit(1);
|
|
81
208
|
}
|
|
82
209
|
export async function devServer(config, opts = {}) {
|
|
83
|
-
|
|
210
|
+
// PORT env wins over the config, so a test harness can bring up its own instance on
|
|
211
|
+
// its own port instead of adopting (or killing — killStrayServer takes the port)
|
|
212
|
+
// the dev server you already have running.
|
|
213
|
+
// `||`, not `??`: an EMPTY `PORT=` is unset, not "port zero". `??` only catches
|
|
214
|
+
// null/undefined, so `PORT=''` yielded `Number('') === 0` — and port 0 fed
|
|
215
|
+
// killStrayServer, whose `lsof -ti:0` matches sockets with an unbound port (system
|
|
216
|
+
// daemons, on this machine). An env var set to empty is the most ordinary shell
|
|
217
|
+
// accident there is; it must not become a kill list.
|
|
218
|
+
const PORT = Number(process.env.PORT || config.port || 8787);
|
|
84
219
|
const PUBLIC = path.resolve('./', config.outputDir ?? 'docs');
|
|
85
220
|
const isSPA = true;
|
|
86
221
|
const testMode = !!opts.test;
|
|
222
|
+
// Don't add a days-long watch process to a machine that is already drowning — and
|
|
223
|
+
// don't quietly become the fourth stale dev server. `buildSite` preflights too, but a
|
|
224
|
+
// consumer's custom `build` need not go through it, and "on launch" is the moment a
|
|
225
|
+
// human is actually looking at the terminal.
|
|
226
|
+
//
|
|
227
|
+
// THROWS; does not `process.exit`. `devServer` is a public export, and library code
|
|
228
|
+
// killing the host process is not ours to do — the caller catches and decides. (The
|
|
229
|
+
// health tick below is different: by then we own a running server on a dying machine,
|
|
230
|
+
// and stopping it is the entire point of the guard.)
|
|
231
|
+
if (!(await preflight({
|
|
232
|
+
label: 'Dev server',
|
|
233
|
+
devLimitMb: config.memoryLimitMb,
|
|
234
|
+
mode: config.preflight,
|
|
235
|
+
}))) {
|
|
236
|
+
throw new Error('dev server: refusing to start — see the machine-health report above. ' +
|
|
237
|
+
'Override with DEV_SKIP_PREFLIGHT=1, or set `preflight: "warn"` in your site config.');
|
|
238
|
+
}
|
|
87
239
|
// Haltija dev-channel — give a coding agent eyes on the running page. Opt-in
|
|
88
240
|
// (config.haltijaDev or HALTIJA_DEV=1), never in test mode. The loader is a
|
|
89
241
|
// localhost-gated runtime import() of the local channel's dev.js, so haltija
|
|
90
242
|
// is never bundled and self-disables off-localhost; it's injected only at
|
|
91
243
|
// serve time (below), so it never lands in the built output.
|
|
244
|
+
//
|
|
245
|
+
// Explicitly OFF under `HALTIJA_DEV=0` and in CI, which both override a config
|
|
246
|
+
// `haltijaDev: true`: the E2E lane starts this server, and there is no agent on
|
|
247
|
+
// the other end of the channel there — spawning it would only download an Electron
|
|
248
|
+
// app into the runner for nobody to look at.
|
|
92
249
|
const HALTIJA_HTTPS_PORT = 8701;
|
|
250
|
+
const haltijaOff = process.env.HALTIJA_DEV === '0' ||
|
|
251
|
+
process.env.HALTIJA_DEV === 'false' ||
|
|
252
|
+
process.env.CI === 'true';
|
|
93
253
|
const haltijaDev = !testMode &&
|
|
254
|
+
!haltijaOff &&
|
|
94
255
|
(config.haltijaDev === true ||
|
|
95
256
|
process.env.HALTIJA_DEV === '1' ||
|
|
96
257
|
process.env.HALTIJA_DEV === 'true');
|
|
@@ -213,6 +374,85 @@ export async function devServer(config, opts = {}) {
|
|
|
213
374
|
});
|
|
214
375
|
}
|
|
215
376
|
}
|
|
377
|
+
// Idle self-exit.
|
|
378
|
+
//
|
|
379
|
+
// The memory ceiling below bounds how bad one server gets; this bounds how many
|
|
380
|
+
// there are. A dev server is trivially forgotten — and a forgotten one is not
|
|
381
|
+
// inert, it is a days-old process still running the code it loaded at launch,
|
|
382
|
+
// leaking whatever that code leaked (updating the package does nothing for a
|
|
383
|
+
// process that is already running). Three of those, left over from before a leak
|
|
384
|
+
// fix landed, wedged a 32GB machine at ~210GB of demand. An idle server has no
|
|
385
|
+
// value to weigh against that, so it exits and lets you start a current one.
|
|
386
|
+
//
|
|
387
|
+
// Activity is a request served or a rebuild — i.e. someone is actually reading or
|
|
388
|
+
// editing. Off in test mode, which has its own timeout and exits on its own.
|
|
389
|
+
const idleMs = resolveIdleMs(config.idleTimeoutHours, process.env.DEV_IDLE_TIMEOUT_HOURS);
|
|
390
|
+
let lastActivity = Date.now();
|
|
391
|
+
const touch = () => {
|
|
392
|
+
lastActivity = Date.now();
|
|
393
|
+
};
|
|
394
|
+
// Memory watchdog.
|
|
395
|
+
//
|
|
396
|
+
// The build hands work to native code (the bundler, the HTML parser, the SVG
|
|
397
|
+
// rasterizer) which can strand memory the JS heap never sees — so nothing
|
|
398
|
+
// here can GC it away, and `heapUsed` stays flat while RSS climbs. A watch
|
|
399
|
+
// process lives for DAYS across thousands of rebuilds, so a per-rebuild leak
|
|
400
|
+
// compounds until the machine swaps itself to death. That is not
|
|
401
|
+
// hypothetical: a 2-day session reached 136GB RSS and took the machine down
|
|
402
|
+
// (Bun.build's native arena — see orchestrator.ts and oven-sh/bun#34053).
|
|
403
|
+
// Dying loudly beats being the reason a laptop overheats: the dev server is
|
|
404
|
+
// one keystroke to restart, the machine is not.
|
|
405
|
+
//
|
|
406
|
+
// Sampled after every rebuild AND on the periodic health tick below. The tick
|
|
407
|
+
// matters: a rebuild-only check is blind to a process that is already over the
|
|
408
|
+
// ceiling but has stopped rebuilding — which is exactly the shape of a server
|
|
409
|
+
// you have walked away from, i.e. the one that kills the machine.
|
|
410
|
+
const rssMb = () => Math.round(process.memoryUsage().rss / 1e6);
|
|
411
|
+
const limitMb = resolveLimitMb(config.memoryLimitMb, process.env.DEV_MEMORY_LIMIT_MB);
|
|
412
|
+
let baselineMb = 0;
|
|
413
|
+
let rebuilds = 0;
|
|
414
|
+
let warned = false;
|
|
415
|
+
const checkMemory = (fromRebuild = true) => {
|
|
416
|
+
if (limitMb <= 0)
|
|
417
|
+
return; // explicitly disabled
|
|
418
|
+
const mb = rssMb();
|
|
419
|
+
if (fromRebuild) {
|
|
420
|
+
rebuilds += 1;
|
|
421
|
+
if (!baselineMb)
|
|
422
|
+
baselineMb = mb;
|
|
423
|
+
}
|
|
424
|
+
if (!baselineMb)
|
|
425
|
+
baselineMb = mb;
|
|
426
|
+
const growth = mb - baselineMb;
|
|
427
|
+
const each = rebuilds > 1 ? growth / (rebuilds - 1) : 0;
|
|
428
|
+
if (mb >= limitMb) {
|
|
429
|
+
// Distinguish the two ways to get here, because the advice is opposite:
|
|
430
|
+
// memory that GREW across rebuilds is a leak (report it); a build that was
|
|
431
|
+
// simply born bigger than the ceiling just needs a bigger ceiling.
|
|
432
|
+
const leaking = rebuilds > 1 && each >= 1;
|
|
433
|
+
const diagnosis = leaking
|
|
434
|
+
? ` ${rebuilds} rebuilds, +${growth}MB since the first ` +
|
|
435
|
+
`(~${each.toFixed(1)}MB per rebuild).\n\n` +
|
|
436
|
+
` Growth per rebuild should be ~0, so this is a leak, not your project\n` +
|
|
437
|
+
` getting bigger. Restarting reclaims it — please report the numbers\n` +
|
|
438
|
+
` above. If this build genuinely needs the headroom, raise the ceiling\n` +
|
|
439
|
+
` with DEV_MEMORY_LIMIT_MB=<mb> or memoryLimitMb in your site config.\n`
|
|
440
|
+
: ` ${rebuilds} rebuild${rebuilds === 1 ? '' : 's'}, ` +
|
|
441
|
+
`+${growth}MB since the first — i.e. it is not growing.\n\n` +
|
|
442
|
+
` This build's baseline footprint is simply above the ceiling, which is\n` +
|
|
443
|
+
` not a leak. Raise it with DEV_MEMORY_LIMIT_MB=<mb> or memoryLimitMb in\n` +
|
|
444
|
+
` your site config.\n`;
|
|
445
|
+
console.error(`\n🛑 dev server stopping: ${mb}MB RSS, over the ${limitMb}MB limit.\n\n` +
|
|
446
|
+
diagnosis);
|
|
447
|
+
process.exit(1);
|
|
448
|
+
}
|
|
449
|
+
if (!warned && mb > limitMb * 0.6) {
|
|
450
|
+
warned = true;
|
|
451
|
+
console.warn(`⚠️ dev server at ${mb}MB RSS after ${rebuilds} rebuilds (+${growth}MB, ` +
|
|
452
|
+
`~${each.toFixed(1)}MB each; limit ${limitMb}MB). Growth per rebuild ` +
|
|
453
|
+
`should be ~0 — if this keeps climbing, restart and report it.`);
|
|
454
|
+
}
|
|
455
|
+
};
|
|
216
456
|
if (!testMode) {
|
|
217
457
|
// Rebuild on any source change. By default that's just buildSite(), but a
|
|
218
458
|
// consumer whose full build has steps BEYOND buildSite — e.g. a custom IIFE
|
|
@@ -223,6 +463,40 @@ export async function devServer(config, opts = {}) {
|
|
|
223
463
|
// never regenerated — leaving the page's /iife.js to 404 into the SPA
|
|
224
464
|
// fallback (it "loads as html"). Serialize builds and coalesce bursts.
|
|
225
465
|
const runBuild = opts.build ?? (() => buildSite(config));
|
|
466
|
+
// Rebuild-storm detector.
|
|
467
|
+
//
|
|
468
|
+
// The other way this process eats the machine is not a leak but a LOOP: if the
|
|
469
|
+
// build writes a file that the watcher watches, every rebuild triggers the next
|
|
470
|
+
// one, forever. Each iteration spawns children (bundler, css, ePub), pegs the
|
|
471
|
+
// CPU, and adds the per-rebuild residual — so a loop is a leak with a throttle
|
|
472
|
+
// removed. The known self-writes (`version.ts`, `icon-data.ts`) are in `ignored`
|
|
473
|
+
// below, but `config.prebuild` is arbitrary consumer code: anything it writes
|
|
474
|
+
// into a watched path loops, and the failure looks like "my fan is on" rather
|
|
475
|
+
// than like a bug.
|
|
476
|
+
//
|
|
477
|
+
// RATE is the wrong signal, and it is worth saying why, because it is the obvious
|
|
478
|
+
// thing to reach for and it does not work: this project's build takes ~3s, so a
|
|
479
|
+
// real loop can only manage ~19 rebuilds a minute — under any "20 a minute" limit
|
|
480
|
+
// you would think to set. Meanwhile a 200ms build loops at 300 a minute. No single
|
|
481
|
+
// rate threshold is both blind to human editing and sensitive to a slow-build loop.
|
|
482
|
+
//
|
|
483
|
+
// The signal that actually separates them is that a loop is SELF-SUSTAINING: the
|
|
484
|
+
// next rebuild is always already queued the instant the current one ends. There is
|
|
485
|
+
// never a human-scale pause, because no human is involved. So count *consecutive
|
|
486
|
+
// immediate* rebuilds, and let any ordinary gap — someone pausing to think, even
|
|
487
|
+
// for two seconds — reset the count.
|
|
488
|
+
const IMMEDIATE_MS = 1500;
|
|
489
|
+
const LOOP_STREAK = 30; // fatal: 30 rebuilds with no human-scale gap between any
|
|
490
|
+
const LOOP_WARN = 15;
|
|
491
|
+
let lastBuildEnd = 0;
|
|
492
|
+
let streak = 0;
|
|
493
|
+
let stormWarned = false;
|
|
494
|
+
const triggers = new Map();
|
|
495
|
+
const topTriggers = () => [...triggers.entries()]
|
|
496
|
+
.sort((a, b) => b[1] - a[1])
|
|
497
|
+
.slice(0, 5)
|
|
498
|
+
.map(([p, n]) => ` ${String(n).padStart(4)}x ${p}`)
|
|
499
|
+
.join('\n');
|
|
226
500
|
let building = false;
|
|
227
501
|
let pending = false;
|
|
228
502
|
const rebuild = async () => {
|
|
@@ -231,6 +505,11 @@ export async function devServer(config, opts = {}) {
|
|
|
231
505
|
return;
|
|
232
506
|
}
|
|
233
507
|
building = true;
|
|
508
|
+
// "Immediate" covers both shapes a loop takes: for a SLOW build the self-write
|
|
509
|
+
// lands mid-build and `pending` is already set when it ends (so the next rebuild
|
|
510
|
+
// starts at once); for a FAST build the watcher event arrives just after the end.
|
|
511
|
+
// Both look like: started again with no gap.
|
|
512
|
+
const immediate = lastBuildEnd > 0 && Date.now() - lastBuildEnd < IMMEDIATE_MS;
|
|
234
513
|
try {
|
|
235
514
|
await runBuild();
|
|
236
515
|
}
|
|
@@ -239,6 +518,26 @@ export async function devServer(config, opts = {}) {
|
|
|
239
518
|
}
|
|
240
519
|
finally {
|
|
241
520
|
building = false;
|
|
521
|
+
lastBuildEnd = Date.now();
|
|
522
|
+
streak = immediate ? streak + 1 : 0;
|
|
523
|
+
touch();
|
|
524
|
+
checkMemory();
|
|
525
|
+
if (streak >= LOOP_STREAK) {
|
|
526
|
+
console.error(`\n🛑 dev server stopping: rebuild loop — ${streak} rebuilds back to back, ` +
|
|
527
|
+
`with no pause between any of them.\n\n` +
|
|
528
|
+
` The files that keep triggering it:\n\n${topTriggers()}\n\n` +
|
|
529
|
+
` A build that writes a file it also watches rebuilds forever, spawning a\n` +
|
|
530
|
+
` bundler every time until the machine gives up — a loop is a leak with the\n` +
|
|
531
|
+
` throttle removed. If a file above is generated by your build, stop writing\n` +
|
|
532
|
+
` it into a watched path, or add it to the watcher's ignore list (\`ignored\`\n` +
|
|
533
|
+
` in dev-server.ts; see the \`prebuild\` notes in the doc-site-system docs).\n`);
|
|
534
|
+
process.exit(1);
|
|
535
|
+
}
|
|
536
|
+
if (streak >= LOOP_WARN && !stormWarned) {
|
|
537
|
+
stormWarned = true;
|
|
538
|
+
console.warn(`⚠️ ${streak} rebuilds back to back with no pause — this looks like a rebuild\n` +
|
|
539
|
+
` loop (a build writing a file it also watches). Top triggers:\n${topTriggers()}`);
|
|
540
|
+
}
|
|
242
541
|
if (pending) {
|
|
243
542
|
pending = false;
|
|
244
543
|
void rebuild();
|
|
@@ -254,7 +553,12 @@ export async function devServer(config, opts = {}) {
|
|
|
254
553
|
'./icons',
|
|
255
554
|
...(config.watchPaths ?? []),
|
|
256
555
|
];
|
|
257
|
-
watch(watchPaths, { ignored, ignoreInitial: true }).on('all', () =>
|
|
556
|
+
watch(watchPaths, { ignored, ignoreInitial: true }).on('all', (_event, changedPath) => {
|
|
557
|
+
if (changedPath) {
|
|
558
|
+
triggers.set(changedPath, (triggers.get(changedPath) ?? 0) + 1);
|
|
559
|
+
}
|
|
560
|
+
void rebuild();
|
|
561
|
+
});
|
|
258
562
|
}
|
|
259
563
|
await ensureDevCerts();
|
|
260
564
|
const server = Bun.serve({
|
|
@@ -264,6 +568,7 @@ export async function devServer(config, opts = {}) {
|
|
|
264
568
|
cert: Bun.file('./tls/certificate.pem'),
|
|
265
569
|
},
|
|
266
570
|
async fetch(request) {
|
|
571
|
+
touch();
|
|
267
572
|
let reqPath = new URL(request.url).pathname;
|
|
268
573
|
console.log(request.method, reqPath);
|
|
269
574
|
if (request.method === 'POST' && reqPath === '/report') {
|
|
@@ -292,6 +597,60 @@ export async function devServer(config, opts = {}) {
|
|
|
292
597
|
},
|
|
293
598
|
});
|
|
294
599
|
console.log(`Listening on https://localhost:${PORT}`);
|
|
600
|
+
// ── health tick ───────────────────────────────────────────────────────────
|
|
601
|
+
//
|
|
602
|
+
// Everything else in this file is edge-triggered: the RSS check fires after a
|
|
603
|
+
// rebuild, the preflight fires at launch. Both are blind to the state that
|
|
604
|
+
// actually kills machines — a server nobody is touching any more, sitting on
|
|
605
|
+
// gigabytes, on a box that is quietly filling up around it. Nothing rebuilds,
|
|
606
|
+
// so nothing looks. So look on a timer, not only on an event.
|
|
607
|
+
//
|
|
608
|
+
// Three checks, cheapest first:
|
|
609
|
+
// 1. RSS ceiling — catches a process already over the line that has stopped
|
|
610
|
+
// rebuilding (the walked-away-from server).
|
|
611
|
+
// 2. Idle exit — bounds how MANY servers exist, not just how big one gets.
|
|
612
|
+
// 3. Machine preflight (every tick) — catches the box going bad around us,
|
|
613
|
+
// including other projects' runaways. If the machine is dying we exit too, and
|
|
614
|
+
// print the PIDs and the kill command on the way out. Exiting IS the guard here,
|
|
615
|
+
// unlike at launch where we throw: by now we own a running server on a dying
|
|
616
|
+
// machine, and the whole point is to stop being part of the problem.
|
|
617
|
+
if (!testMode) {
|
|
618
|
+
if (idleMs > 0) {
|
|
619
|
+
console.log(`Exits after ${idleMs / 3600_000}h idle (DEV_IDLE_TIMEOUT_HOURS=0 to disable).`);
|
|
620
|
+
}
|
|
621
|
+
// Every minute, not every five. The runaway that killed the machine went 0→100GB
|
|
622
|
+
// in about twenty minutes; on a box whose dev budget is a fraction of its RAM
|
|
623
|
+
// (most of it held by a resident model), the trip from healthy to unrecoverable
|
|
624
|
+
// fits comfortably inside a five-minute window — and with swap on a fast, large
|
|
625
|
+
// disk, macOS will thrash for a very long time before it gives up, which is more
|
|
626
|
+
// rope to hang the machine with, not less. A `ps` + `vm_stat` per minute is free.
|
|
627
|
+
const TICK_MS = 60_000;
|
|
628
|
+
const timer = setInterval(async () => {
|
|
629
|
+
checkMemory(false); // exits if we are over the ceiling
|
|
630
|
+
const idleFor = Date.now() - lastActivity;
|
|
631
|
+
if (idleMs > 0 && idleFor >= idleMs) {
|
|
632
|
+
console.log(`\n💤 dev server exiting: idle for ${Math.round(idleFor / 3600_000)}h — no requests, no rebuilds.\n\n` +
|
|
633
|
+
` Restart it when you need it; a fresh one also picks up any dependency\n` +
|
|
634
|
+
` updates, which a long-running process never does. Disable with\n` +
|
|
635
|
+
` DEV_IDLE_TIMEOUT_HOURS=0 or idleTimeoutHours in your site config.\n`);
|
|
636
|
+
clearInterval(timer);
|
|
637
|
+
server.stop();
|
|
638
|
+
process.exit(0);
|
|
639
|
+
}
|
|
640
|
+
const ok = await preflight({
|
|
641
|
+
label: 'Dev server',
|
|
642
|
+
devLimitMb: config.memoryLimitMb,
|
|
643
|
+
mode: config.preflight,
|
|
644
|
+
});
|
|
645
|
+
if (!ok) {
|
|
646
|
+
clearInterval(timer);
|
|
647
|
+
server.stop();
|
|
648
|
+
process.exit(1);
|
|
649
|
+
}
|
|
650
|
+
}, TICK_MS);
|
|
651
|
+
// Never let the health check itself be the thing keeping the process alive.
|
|
652
|
+
timer.unref?.();
|
|
653
|
+
}
|
|
295
654
|
if (haltijaDev) {
|
|
296
655
|
await ensureHaltijaChannel(HALTIJA_HTTPS_PORT);
|
|
297
656
|
}
|
|
@@ -313,9 +672,13 @@ export async function devServer(config, opts = {}) {
|
|
|
313
672
|
}
|
|
314
673
|
if (!hjAvailable) {
|
|
315
674
|
console.log('Starting haltija...');
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
675
|
+
// stdout/stderr are NOT inherited: `bunx haltija` launches an Electron app as a
|
|
676
|
+
// grandchild, and killing the bunx wrapper does not kill it. An orphaned Electron
|
|
677
|
+
// holding our inherited stdout keeps the pipe open forever, so a CI job or an
|
|
678
|
+
// agent capturing this command's output sees it hang long after it exited (0).
|
|
679
|
+
haltija = spawn(['bunx', HALTIJA_PKG, '-f'], {
|
|
680
|
+
stdout: 'ignore',
|
|
681
|
+
stderr: 'ignore',
|
|
319
682
|
});
|
|
320
683
|
console.log('Waiting for browser...');
|
|
321
684
|
for (let i = 0; i < 20; i++) {
|
|
@@ -341,20 +704,82 @@ export async function devServer(config, opts = {}) {
|
|
|
341
704
|
else {
|
|
342
705
|
console.log('Using existing haltija browser');
|
|
343
706
|
}
|
|
707
|
+
/**
|
|
708
|
+
* Tear down the haltija WE started — including the Electron grandchild.
|
|
709
|
+
*
|
|
710
|
+
* `haltija.kill()` only signals the `bunx` wrapper; the Electron app it launched
|
|
711
|
+
* survives. A surviving instance leaves stale windows behind, and the next
|
|
712
|
+
* `--test` run reuses any haltija reporting `windows.length > 0` — so the run
|
|
713
|
+
* after this one navigates a dead window, never POSTs /report, and fails with
|
|
714
|
+
* "Browser tests timed out" on a perfectly good codebase.
|
|
715
|
+
*
|
|
716
|
+
* The predicate is **descendants of the process we spawned** — NOT `pkill -f
|
|
717
|
+
* haltija/apps/desktop`, which is what this used to do while claiming in this very
|
|
718
|
+
* comment to leave your own haltija alone. `pkill -f` matches every process on the
|
|
719
|
+
* machine whose command line contains that string, so it killed the haltija YOU
|
|
720
|
+
* were running too. And it is the *test suite* that runs this: we spawn our own
|
|
721
|
+
* whenever yours reports zero windows, so "haltija open, no windows, run the doc
|
|
722
|
+
* tests" silently killed your browser. A test lane that reaches outside the repo
|
|
723
|
+
* and kills the developer's tools presents as "my tools got weird", never as a red
|
|
724
|
+
* test.
|
|
725
|
+
*
|
|
726
|
+
* The tree is collected BEFORE the wrapper dies: once it is gone, Electron is
|
|
727
|
+
* reparented to init and `pgrep -P` can no longer find it.
|
|
728
|
+
*/
|
|
729
|
+
const descendantsOf = async (pid) => {
|
|
730
|
+
const out = await $ `pgrep -P ${pid}`
|
|
731
|
+
.quiet()
|
|
732
|
+
.text()
|
|
733
|
+
.catch(() => '');
|
|
734
|
+
const kids = out
|
|
735
|
+
.trim()
|
|
736
|
+
.split('\n')
|
|
737
|
+
.filter(Boolean)
|
|
738
|
+
.map(Number)
|
|
739
|
+
.filter((n) => Number.isInteger(n) && n > 0);
|
|
740
|
+
const all = [...kids];
|
|
741
|
+
for (const kid of kids)
|
|
742
|
+
all.push(...(await descendantsOf(kid)));
|
|
743
|
+
return all;
|
|
744
|
+
};
|
|
745
|
+
const stopHaltija = async () => {
|
|
746
|
+
if (!haltija?.pid)
|
|
747
|
+
return;
|
|
748
|
+
const tree = await descendantsOf(haltija.pid);
|
|
749
|
+
haltija.kill();
|
|
750
|
+
for (const pid of tree) {
|
|
751
|
+
try {
|
|
752
|
+
process.kill(pid, 'SIGTERM');
|
|
753
|
+
}
|
|
754
|
+
catch {
|
|
755
|
+
// already gone — fine
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
};
|
|
344
759
|
console.log('Opening demo site...');
|
|
345
|
-
|
|
760
|
+
// haltija 1.4: `hj navigate` exits NON-ZERO on failure (no browser reachable, a
|
|
761
|
+
// window that didn't take the URL, …). Before 1.4 it exited 0 regardless, so a
|
|
762
|
+
// failed navigate silently sailed on and the run died 120s later at the test
|
|
763
|
+
// timeout with a misleading "Browser tests timed out". Now we can read the exit
|
|
764
|
+
// code: fail immediately, say why, and tear down the haltija we spawned so the
|
|
765
|
+
// next run isn't inheriting a half-navigated browser.
|
|
766
|
+
const nav = await $ `hj navigate https://localhost:${PORT}`.nothrow().quiet();
|
|
767
|
+
if (nav.exitCode !== 0) {
|
|
768
|
+
console.error(`hj navigate failed (exit ${nav.exitCode}): ${nav.stderr.toString().trim() || 'no browser reachable'}`);
|
|
769
|
+
await stopHaltija();
|
|
770
|
+
server.stop();
|
|
771
|
+
process.exit(1);
|
|
772
|
+
}
|
|
346
773
|
try {
|
|
347
774
|
const results = await testResults;
|
|
348
775
|
const exitCode = results.failed > 0 ? 1 : 0;
|
|
349
|
-
|
|
350
|
-
haltija.kill();
|
|
776
|
+
await stopHaltija();
|
|
351
777
|
server.stop();
|
|
352
778
|
process.exit(exitCode);
|
|
353
779
|
}
|
|
354
780
|
catch (e) {
|
|
355
781
|
console.error(e.message);
|
|
356
|
-
|
|
357
|
-
haltija.kill();
|
|
782
|
+
await stopHaltija();
|
|
358
783
|
server.stop();
|
|
359
784
|
process.exit(1);
|
|
360
785
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Standalone ePub build — the subprocess entry point for buildEpub().
|
|
3
|
+
|
|
4
|
+
buildEpub() drives happy-dom (HTML→XHTML for 50+ chapters) and @resvg/resvg-js
|
|
5
|
+
(cover rasterization). Both hold native/retained memory that the JS heap can't
|
|
6
|
+
see, and the ePub is rebuilt on EVERY dev-server rebuild — so in-process it
|
|
7
|
+
strands memory in a watch process that lives for days. Running it in a child
|
|
8
|
+
hands all of it back to the OS on exit. Same reasoning as the bundle step; see
|
|
9
|
+
the Bun.build note in orchestrator.ts.
|
|
10
|
+
|
|
11
|
+
bun epub-cli.ts <payload.json> # payload = { config, opts }
|
|
12
|
+
|
|
13
|
+
Build-time only. Never import this from browser code.
|
|
14
|
+
*/
|
|
15
|
+
import { buildEpub } from './epub';
|
|
16
|
+
const payloadPath = process.argv[2];
|
|
17
|
+
if (!payloadPath) {
|
|
18
|
+
console.error('epub-cli: expected a payload file path');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const { config, opts } = JSON.parse(await Bun.file(payloadPath).text());
|
|
22
|
+
await buildEpub(config, opts ?? {});
|
|
@@ -535,6 +535,12 @@ export async function buildEpub(config, opts = {}) {
|
|
|
535
535
|
const output = path.resolve(opts.output ?? path.join(outDir, `${slugify(meta.title)}.epub`));
|
|
536
536
|
await zipEpub(buildDir, output);
|
|
537
537
|
fs.rmSync(buildDir, { recursive: true, force: true });
|
|
538
|
+
// Release the parser window: an unclosed happy-dom Window holds its whole
|
|
539
|
+
// document (every chapter parsed) plus its timers. Hygiene, not a proven win —
|
|
540
|
+
// buildEpub still grows ~1.5MB per call with this in place, so something here
|
|
541
|
+
// is still retained. buildEpub now runs in a child process (see orchestrator),
|
|
542
|
+
// which is what actually bounds it.
|
|
543
|
+
win?.close?.();
|
|
538
544
|
console.log(`epub: ${output} (${chapters.length} chapters)`);
|
|
539
545
|
return output;
|
|
540
546
|
}
|
|
@@ -10,6 +10,7 @@ the IIFE bundle loads.
|
|
|
10
10
|
Build-time only (uses Bun.write). Shares slug + markdown rendering with the runtime
|
|
11
11
|
component (src/doc-system/*) so static and hydrated output agree.
|
|
12
12
|
*/
|
|
13
|
+
import { pageTitle } from '../doc-title';
|
|
13
14
|
import { buildSlugMap, pathForSlug, rewriteDocLinks } from '../routing';
|
|
14
15
|
import { buildNavTree, navOpenPath } from '../nav-tree';
|
|
15
16
|
import { renderDocMarkdown, docDescription } from '../render';
|
|
@@ -80,12 +81,9 @@ function pageHtml(doc, config, slugMap, configAttr) {
|
|
|
80
81
|
const localizedAttr = config.localizedStrings
|
|
81
82
|
? ` localized="${escapeAttr(withBase(basePath, localizedUrl))}"`
|
|
82
83
|
: '';
|
|
83
|
-
//
|
|
84
|
-
// the
|
|
85
|
-
const title = doc
|
|
86
|
-
(projectName && !doc.title.includes(projectName)
|
|
87
|
-
? `${doc.title} — ${projectName}`
|
|
88
|
-
: doc.title);
|
|
84
|
+
// ONE rule, shared with the doc-browser's hydration path — see doc-title.ts. Writing
|
|
85
|
+
// it twice is what made the title change on hydration (issue #6).
|
|
86
|
+
const title = pageTitle(doc, projectName);
|
|
89
87
|
// Per-page metadata (from the doc's JSON block) wins; else derive, else site default.
|
|
90
88
|
const description = doc.description || docDescription(doc.text) || config.description || '';
|
|
91
89
|
const keywords = Array.isArray(doc.keywords)
|
|
@@ -102,21 +102,6 @@ export function generateLlmsTxt(outputPath, meta = {}, corpus) {
|
|
|
102
102
|
console.warn('llms.txt: no readable package.json — using config values only');
|
|
103
103
|
}
|
|
104
104
|
const entries = corpus ? entriesFromCorpus(corpus, meta) : entriesFromSrcScan();
|
|
105
|
-
// Only orient agents about the live-example execution model when the corpus
|
|
106
|
-
// actually has live examples (a book / pure-docs site has none).
|
|
107
|
-
const exampleFence = /```(js|tjs|ts|html|css)\b/;
|
|
108
|
-
const hasLiveExamples = !!corpus && corpus.some((d) => exampleFence.test(d.text ?? ''));
|
|
109
|
-
const liveExampleNote = hasLiveExamples
|
|
110
|
-
? [
|
|
111
|
-
'Live examples run inline on the shared page by default (not sandboxed):',
|
|
112
|
-
'each has its own `preview` element, but they share one tosijs module, so',
|
|
113
|
-
'`tosi()` state singletons are shared across every example on a page. This is',
|
|
114
|
-
'deliberate (it shows tosi isolation and lets you drive demos via the global',
|
|
115
|
-
'singleton). Namespace example state with unique top-level keys so examples',
|
|
116
|
-
"don't clobber each other; the `iframe` attribute isolates DOM/CSS, not state.",
|
|
117
|
-
'',
|
|
118
|
-
]
|
|
119
|
-
: [];
|
|
120
105
|
const name = meta.name ?? pkg.name ?? '';
|
|
121
106
|
const description = meta.description ?? pkg.description ?? '';
|
|
122
107
|
const links = [];
|
|
@@ -140,7 +125,6 @@ export function generateLlmsTxt(outputPath, meta = {}, corpus) {
|
|
|
140
125
|
'',
|
|
141
126
|
'Full documentation, with live code examples, is at the links below.',
|
|
142
127
|
'',
|
|
143
|
-
...liveExampleNote,
|
|
144
128
|
'## Pages',
|
|
145
129
|
'',
|
|
146
130
|
];
|