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.
@@ -12,32 +12,135 @@ the icon system here would put src/icon-data.ts into `bun --watch`'s graph and
12
12
  cause an endless rebuild loop.
13
13
  */
14
14
  import * as path from 'path';
15
- import { existsSync, mkdirSync } from 'fs';
16
- import { gzipSync } from 'zlib';
17
- import { $ } from 'bun';
15
+ import { existsSync, mkdirSync, unlinkSync } from 'fs';
16
+ import { tmpdir } from 'os';
17
+ import { $, spawn } from 'bun';
18
18
  import { extractDocs } from './docs';
19
- import { checkExamples, formatExampleProblems } from './check-examples';
19
+ import { preflight } from './preflight';
20
+ import { checkExamples, formatExampleProblems, } from './check-examples';
20
21
  import { ensureSections } from './sections';
21
22
  import { generateLlmsTxt } from './make-llms-txt';
22
23
  import { generateSite } from './generate-site';
23
24
  import { findOutputDirOverlap } from './output-guard';
24
- import { buildEpub } from './epub';
25
+ // Module specifiers contain regex metacharacters (`/`, `.`, `@`, …), so escape
26
+ // before interpolating into the require-shim detector below.
27
+ /** Give up on a hung ePub child rather than wedge the dev server's rebuild. */
28
+ const EPUB_TIMEOUT_MS = 120_000;
29
+ /**
30
+ * Run buildEpub() in a child process.
31
+ *
32
+ * buildEpub drives happy-dom (HTML→XHTML for every chapter) and @resvg/resvg-js
33
+ * (cover raster) — both native, both retaining — and it runs on EVERY dev rebuild,
34
+ * so in-process it strands memory in a watch process that lives for days. The child
35
+ * hands it all back on exit. If it hangs we kill it, and if it fails we warn: the
36
+ * ePub is a side artifact, so neither should block the page you're trying to look at.
37
+ *
38
+ * Only the data buildEpub reads is forwarded — SiteConfig also carries functions
39
+ * (llmsTxt, libraryBuild, prebuild) that don't survive JSON.
40
+ */
41
+ async function buildEpubInChild(config, opts) {
42
+ // Resolve the sibling relative to THIS module so it works both in-repo (.ts)
43
+ // and when shipped (compiled .js) — same trick as generate-css below.
44
+ const cliTs = `${import.meta.dir}/epub-cli.ts`;
45
+ const cli = existsSync(cliTs) ? cliTs : `${import.meta.dir}/epub-cli.js`;
46
+ const payload = {
47
+ config: {
48
+ basePath: config.basePath,
49
+ baseUrl: config.baseUrl,
50
+ book: config.book,
51
+ docsJson: config.docsJson,
52
+ favicon: config.favicon,
53
+ lang: config.lang,
54
+ name: config.name,
55
+ outputDir: config.outputDir,
56
+ },
57
+ opts,
58
+ };
59
+ const payloadPath = path.join(tmpdir(), `tosijs-epub-${process.pid}.json`);
60
+ await Bun.write(payloadPath, JSON.stringify(payload));
61
+ const child = spawn(['bun', cli, payloadPath], {
62
+ stdout: 'inherit',
63
+ stderr: 'inherit',
64
+ });
65
+ const killer = setTimeout(() => {
66
+ console.warn(`⚠️ epub build exceeded ${EPUB_TIMEOUT_MS / 1000}s — killing it. The site is\n` +
67
+ ` fine; the .epub may be stale.`);
68
+ child.kill();
69
+ }, EPUB_TIMEOUT_MS);
70
+ try {
71
+ const code = await child.exited;
72
+ if (code !== 0) {
73
+ console.warn(`⚠️ epub build failed (exit ${code}). The site is fine; the .epub may be stale.`);
74
+ }
75
+ }
76
+ finally {
77
+ clearTimeout(killer);
78
+ try {
79
+ unlinkSync(payloadPath);
80
+ }
81
+ catch {
82
+ // already gone — fine
83
+ }
84
+ }
85
+ }
25
86
  // Module specifiers contain regex metacharacters (`/`, `.`, `@`, …), so escape
26
87
  // before interpolating into the require-shim detector below.
27
88
  const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
28
- // Externalize the tjs CodeMirror editor extension ONLY when tjs-lang isn't
29
- // installed — otherwise it's bundled in so it shares the editor's CodeMirror
30
- // instance (see the `external` comment in buildSite). Returns [] when present.
31
- function tjsEditorExternal(root) {
89
+ /**
90
+ * Run checkExamples() in a child process.
91
+ *
92
+ * It compiles every executable block in the corpus with `new AsyncFunction` on every
93
+ * rebuild — and JSC caches compiled code keyed by source text, so it retains, and
94
+ * (because a rebuild only happens when a file CHANGED) the source is fresh every time
95
+ * and nothing dedups. It also constructs a `Bun.Transpiler` per `ts` block (~40KB
96
+ * stranded per construction — oven-sh/bun#34053). Both accumulate in a watch process
97
+ * that lives for days; the child gives it all back on exit. Falls back to in-process
98
+ * if the child can't run — a build must not break because the leak-safe path is
99
+ * unavailable.
100
+ */
101
+ async function checkExamplesInChild(docsJson) {
102
+ const cliTs = `${import.meta.dir}/check-examples-cli.ts`;
103
+ const cli = existsSync(cliTs)
104
+ ? cliTs
105
+ : `${import.meta.dir}/check-examples-cli.js`;
32
106
  try {
33
- Bun.resolveSync('tjs-lang/editors/codemirror', root);
34
- return [];
107
+ const child = spawn(['bun', cli, docsJson], {
108
+ stdout: 'pipe',
109
+ stderr: 'pipe',
110
+ });
111
+ // Drain BOTH pipes while awaiting exit. An undrained pipe fills its buffer, the
112
+ // child blocks writing to it, and we deadlock waiting for an exit that can't come.
113
+ const [out, err, code] = await Promise.all([
114
+ new Response(child.stdout).text(),
115
+ new Response(child.stderr).text(),
116
+ child.exited,
117
+ ]);
118
+ if (err.trim())
119
+ console.warn(err.trim());
120
+ if (code !== 0)
121
+ throw new Error(`check-examples exited ${code}`);
122
+ return JSON.parse(out);
35
123
  }
36
- catch {
37
- return ['tjs-lang/editors/codemirror'];
124
+ catch (e) {
125
+ console.warn(`⚠️ example check: could not run it in a child (${String(e)}) — ` +
126
+ `falling back to in-process.`);
127
+ const corpus = JSON.parse(await Bun.file(docsJson).text());
128
+ return checkExamples(corpus);
38
129
  }
39
130
  }
40
131
  export async function buildSite(config) {
132
+ // Look at the machine before adding load to it — a build runs on every watch
133
+ // rebuild, so a dying box (a runaway dev server elsewhere, a VM stall) is caught
134
+ // here too, not just at dev-server launch. Returns false rather than exiting: this
135
+ // is a public export, and an adopter's `await buildSite(cfg); await deploy()` must
136
+ // not be killed from inside a health check they never asked for.
137
+ if (!(await preflight({
138
+ label: 'Build',
139
+ devLimitMb: config.memoryLimitMb,
140
+ mode: config.preflight,
141
+ }))) {
142
+ return false;
143
+ }
41
144
  const PROJECT_ROOT = './';
42
145
  const PUBLIC = path.resolve(PROJECT_ROOT, config.outputDir ?? 'docs');
43
146
  const DIST = path.resolve(PROJECT_ROOT, 'dist');
@@ -86,8 +189,7 @@ export async function buildSite(config) {
86
189
  // display-only `typescript`. Runs on the whole corpus, so it catches breakage
87
190
  // on pages the browser test never navigates to.
88
191
  if (config.checkExamples !== false) {
89
- const corpus = JSON.parse(await Bun.file(DOCS_JSON).text());
90
- const problems = await checkExamples(corpus);
192
+ const problems = await checkExamplesInChild(DOCS_JSON);
91
193
  if (problems.length) {
92
194
  throw new Error(`doc-site build: ${problems.length} live example(s) failed to build:\n\n` +
93
195
  formatExampleProblems(problems) +
@@ -142,36 +244,47 @@ export async function buildSite(config) {
142
244
  // consumer supplies (e.g. via staticDirs or an absolute URL).
143
245
  const scriptName = (config.scriptUrl ?? '/iife.js').replace(/^\//, '');
144
246
  if (config.bundleEntry) {
145
- const result = await Bun.build({
146
- entrypoints: [config.bundleEntry],
147
- outdir: DIST,
148
- sourcemap: 'linked',
149
- format: 'iife',
150
- minify: true,
151
- naming: scriptName,
152
- // tjs-lang's TRANSPILER (browser bundles) is dynamically import()'d at
153
- // runtime (same-origin `/tjs/` copy, else CDN), so keep it out of the bundle.
154
- //
155
- // `tjs-lang/editors/codemirror` (the CodeMirror language + autocomplete) is
156
- // different: it MUST share the editor's CodeMirror instance, so it has to be
157
- // bundled IN — a separately loaded copy carries its own `@codemirror/state`
158
- // and silently no-ops. So we do NOT externalize it *when tjs-lang is
159
- // installed*. But tjs-lang is an OPTIONAL peer; if it's absent, bundling would
160
- // fail to resolve, so we externalize it in that case (the runtime import then
161
- // no-ops to plain TS highlighting — graceful degradation).
162
- external: [
163
- 'tjs-lang/browser',
164
- 'tjs-lang/browser/from-ts',
165
- ...tjsEditorExternal(PROJECT_ROOT),
166
- ...(config.bundleExternals ?? []),
167
- ],
168
- });
169
- if (!result.success) {
247
+ // tjs-lang transpiles live examples; it's dynamically import()'d at
248
+ // runtime (falling back to CDN), so keep it out of the bundle.
249
+ const externals = [
250
+ 'tjs-lang',
251
+ 'tjs-lang/browser',
252
+ 'tjs-lang/browser/from-ts',
253
+ ...(config.bundleExternals ?? []),
254
+ ];
255
+ // Bundle in a CHILD PROCESS, not via Bun.build().
256
+ //
257
+ // Bun.build() never gives back the bundler's native arena: measured at ~9MB
258
+ // of RSS per call and rising, monotonic, with no plateau (40 sequential
259
+ // builds of one real entry = +367MB, still climbing ~5MB/build at the end),
260
+ // while the JS heap stays flat — so it is invisible to Bun.gc() and to any
261
+ // heap profiler. devServer() calls this once per rebuild in a process that
262
+ // lives for DAYS, so it compounds: a ~2-day watch session reached 136GB RSS
263
+ // and took the machine down with it. Filed as oven-sh/bun#34053.
264
+ //
265
+ // The CLI does identical work in a child whose memory the OS reclaims on
266
+ // exit: the same 15 bundles leave the parent +0.5MB instead of +192MB.
267
+ // Keep these flags in sync with the Bun.build() options they replace.
268
+ const bundle = spawn([
269
+ 'bun',
270
+ 'build',
271
+ config.bundleEntry,
272
+ '--outdir',
273
+ DIST,
274
+ '--sourcemap=linked',
275
+ '--format=iife',
276
+ '--minify',
277
+ '--entry-naming',
278
+ scriptName,
279
+ ...externals.flatMap((ext) => ['--external', ext]),
280
+ ], { stdout: 'inherit', stderr: 'inherit' });
281
+ if ((await bundle.exited) !== 0) {
170
282
  console.error('bundle build failed');
171
- for (const message of result.logs)
172
- console.error(message);
173
283
  return false;
174
284
  }
285
+ await $ `cp ${DIST}/${scriptName} ${PUBLIC}`.text();
286
+ const bundleFile = await Bun.file(`${DIST}/${scriptName}`).arrayBuffer();
287
+ const bundleJs = Buffer.from(bundleFile).toString('utf8');
175
288
  // Warn only when an external actually compiled to a synchronous require()
176
289
  // shim, which throws at module-eval ("Dynamic require of … is not
177
290
  // supported"). That only happens for a *static* `import x from 'ext'`. A
@@ -180,26 +293,37 @@ export async function buildSite(config) {
180
293
  // stay silent. The config alone can't tell the two apart; the emitted
181
294
  // bundle can, so we inspect the actual output.
182
295
  if (config.bundleExternals && config.bundleExternals.length > 0) {
183
- const js = (await Promise.all(result.outputs
184
- .filter((o) => o.kind === 'entry-point' || o.path.endsWith('.js'))
185
- .map((o) => o.text()))).join('\n');
186
- const broken = config.bundleExternals.filter((ext) => new RegExp(`(?:__require|\\brequire)\\(\\s*["'\`]${escapeRegExp(ext)}["'\`]`).test(js));
296
+ const broken = config.bundleExternals.filter((ext) => new RegExp(`(?:__require|\\brequire)\\(\\s*["'\`]${escapeRegExp(ext)}["'\`]`).test(bundleJs));
187
297
  if (broken.length > 0) {
188
298
  console.warn(`⚠️ bundleExternals compiled to a synchronous require() shim that throws at runtime (${broken.join(', ')}). Reference these via a dynamic import() (kept async by the bundler)\n` +
189
299
  ` or an importmap, not a static import.`);
190
300
  }
191
301
  }
192
- await $ `cp ${DIST}/${scriptName} ${PUBLIC}`.text();
193
- const bundleFile = await Bun.file(`${DIST}/${scriptName}`).arrayBuffer();
194
302
  // import.meta is illegal in a classic <script> — if it survived bundling
195
303
  // (a branch the bundler couldn't eliminate) the IIFE will SyntaxError.
196
- if (Buffer.from(bundleFile).toString('utf8').includes('import.meta')) {
304
+ if (bundleJs.includes('import.meta')) {
197
305
  console.warn(`⚠️ ${scriptName} contains \`import.meta\`, which is a SyntaxError in a classic <script>.\n` +
198
306
  ` A dependency referenced it in a branch the bundler couldn't drop — mark that dep external\n` +
199
307
  ` (+ importmap) or choose a browser-only entry point.`);
200
308
  }
201
- const bundleGzip = gzipSync(Buffer.from(bundleFile));
202
- console.log(`${scriptName}: ${(bundleFile.byteLength / 1024).toFixed(1)}kb (${(bundleGzip.length / 1024).toFixed(1)}kb gzip)`);
309
+ // Gzip in a CHILD, not in-process. zlib's gzip is native, so it strands memory the
310
+ // JS heap never sees; this runs on every rebuild over a ~1MB bundle to print ONE
311
+ // size line, in a process that lives for days. Deliberately zlib-in-a-child rather
312
+ // than the `gzip` CLI: the two disagree by ~1.6%, and this number is tracked across
313
+ // releases, so moving the work must not move the measurement. Best-effort — a
314
+ // missing size line must never fail a build.
315
+ let gzipKb = '';
316
+ try {
317
+ const bytes = Number((await $ `bun -e ${`const {gzipSync}=require('zlib');const b=await Bun.file(${JSON.stringify(`${DIST}/${scriptName}`)}).arrayBuffer();process.stdout.write(String(gzipSync(Buffer.from(b)).length))`}`
318
+ .quiet()
319
+ .text()).trim());
320
+ if (bytes > 0)
321
+ gzipKb = ` (${(bytes / 1024).toFixed(1)}kb gzip)`;
322
+ }
323
+ catch {
324
+ // couldn't measure — report the raw size alone
325
+ }
326
+ console.log(`${scriptName}: ${(bundleFile.byteLength / 1024).toFixed(1)}kb${gzipKb}`);
203
327
  }
204
328
  else if (!/^(https?:)?\/\//.test(config.scriptUrl ?? '/iife.js')) {
205
329
  // No custom bundleEntry (the normal case for a pure-docs / book site) and a
@@ -319,7 +443,7 @@ export async function buildSite(config) {
319
443
  // the corpus and survives the `rm -rf <outputDir>` at the top of the NEXT build
320
444
  // (otherwise a dev rebuild would silently drop it). Cheap (~0.4s).
321
445
  if (config.epub) {
322
- await buildEpub(config, typeof config.epub === 'object' ? config.epub : {});
446
+ await buildEpubInChild(config, typeof config.epub === 'object' ? config.epub : {});
323
447
  }
324
448
  console.timeEnd('build');
325
449
  return true;
@@ -0,0 +1,152 @@
1
+ export interface ProcInfo {
2
+ pid: number;
3
+ rssMb: number;
4
+ /** elapsed time as `ps` reports it, e.g. `01:23:45` or `2-04:11:07` */
5
+ etime: string;
6
+ command: string;
7
+ }
8
+ export interface Assessment {
9
+ level: 'ok' | 'warn' | 'fail';
10
+ /** processes worth naming, worst first */
11
+ offenders: ProcInfo[];
12
+ /** human-readable reason, empty when ok */
13
+ reason: string;
14
+ }
15
+ /**
16
+ * Parse `ps -eo pid=,rss=,etime=,args=` output. Bytes are KB in `ps`; the command
17
+ * is everything after the third field, so it may contain spaces.
18
+ */
19
+ export declare function parsePs(output: string): ProcInfo[];
20
+ /** `ps` etime — `[[dd-]hh:]mm:ss` — as hours. */
21
+ export declare function etimeHours(etime: string): number;
22
+ /**
23
+ * How much RAM is actually available to *dev work* — the denominator every
24
+ * percentage below is taken against.
25
+ *
26
+ * This is the whole trick, and it is worth being explicit about why it is not
27
+ * `totalmem()`. **Percentages of physical RAM scale with the hardware; headroom does
28
+ * not.** Buy 96GB more memory and spend all of it on a resident model, and the slack
29
+ * available to a runaway build is exactly what it was before — but every
30
+ * percentage-of-RAM threshold has quadrupled, so the guard gets four times laxer on
31
+ * the machine where it needed to be just as strict. On a 128GB box with a 96GB model
32
+ * up, a 50%-of-RAM "catastrophic" bar is 64GB: double the headroom that exists, so it
33
+ * can never fire in time.
34
+ *
35
+ * Budget = RAM − (what exempt processes hold) − (`reservedMb`), floored at
36
+ * `minBudgetPct` of RAM so a machine that is genuinely fully committed still gets
37
+ * sane thresholds rather than absurd ones. On the 128GB/96GB box that yields a ~32GB
38
+ * budget — the same absolute thresholds that are right on a 32GB laptop, because the
39
+ * slack is the same. Unload the model and the budget floats back up on its own.
40
+ *
41
+ * `devLimitMb` is deliberately NOT a percentage: it survives a hardware change
42
+ * precisely because it is absolute. 4GB is too big for a dev server on any machine.
43
+ */
44
+ export declare function memoryBudgetMb(procs: ProcInfo[], opts: {
45
+ totalRamMb: number;
46
+ reservedMb?: number;
47
+ minBudgetPct?: number;
48
+ }): number;
49
+ /**
50
+ * Judge the process table against physical RAM.
51
+ *
52
+ * THREE per-process triggers. Note what is deliberately NOT here: a rule that SUMS
53
+ * RSS across processes. It is the obvious idea and it is wrong on macOS — at the
54
+ * moment of the fatal crash, total RSS across 629 processes was ~225GB against 32GB
55
+ * of RAM, and the box was still limping. Compression and swap mean summed footprint
56
+ * routinely exceeds physical RAM on a perfectly healthy machine, so an aggregate-RSS
57
+ * rule would fire during ordinary work and get switched off within a week. A SINGLE
58
+ * process at half the machine is genuinely anomalous; the sum is not. (For a real
59
+ * machine-level signal, see `assessMemoryPressure` below — VM pressure, not RSS
60
+ * arithmetic.)
61
+ *
62
+ * Every percentage is taken against the **dev memory budget** (`memoryBudgetMb`), NOT
63
+ * against physical RAM — see that function for why. Exempt processes (a resident LLM,
64
+ * say) can never be offenders; they shrink the budget instead.
65
+ *
66
+ * - **Any process over `catastrophicPct` of the budget** (default 50%). Nothing
67
+ * healthy is half the available machine. Not limited to dev processes — if
68
+ * something else is eating the box, adding a build to it is still the wrong move.
69
+ * - **A dev process over `hugeDevPct` of the budget** (default 25%), *regardless of
70
+ * age*. The age gate below is a real blind spot: the runaway that took the machine
71
+ * down went 0→100GB in about twenty minutes, so it spent its entire climb through
72
+ * the 4–16GB band both too young to be "stale" and too small to be "catastrophic" —
73
+ * i.e. invisible for the whole ascent. No legitimate dev process is a quarter of
74
+ * the available machine, young or not.
75
+ * - **A long-lived dev process over `devLimitMb`** (default: the dev server's own RSS
76
+ * ceiling). Absolute, not a percentage — 4GB is too big for a dev server on any
77
+ * machine, and an absolute limit survives a hardware upgrade. Age-gated so a build
78
+ * legitimately peaking mid-run isn't flagged.
79
+ *
80
+ * `selfPid` is excluded throughout — this process's own growth is the memory
81
+ * watchdog's job, and it is bounded there.
82
+ */
83
+ export declare function assessProcesses(procs: ProcInfo[], opts: {
84
+ totalRamMb: number;
85
+ selfPid?: number;
86
+ devLimitMb?: number;
87
+ catastrophicPct?: number;
88
+ hugeDevPct?: number;
89
+ minAgeHours?: number;
90
+ /** RAM to hold back for known-heavy non-dev work, on top of exempt processes */
91
+ reservedMb?: number;
92
+ }): Assessment;
93
+ export interface VmPressure {
94
+ freeMb: number;
95
+ compressorMb: number;
96
+ swapUsedMb: number;
97
+ }
98
+ /** Parse `vm_stat`. Page size is in its header ("page size of 16384 bytes"). */
99
+ export declare function parseVmStat(output: string): {
100
+ freeMb: number;
101
+ compressorMb: number;
102
+ };
103
+ /** Parse `sysctl vm.swapusage` → used MB. */
104
+ export declare function parseSwapUsage(output: string): number;
105
+ /**
106
+ * Judge VM pressure.
107
+ *
108
+ * Requires BOTH a swollen compressor AND almost no free memory. Either alone is
109
+ * normal: macOS keeps free memory low on purpose (it is caching, not idling), and a
110
+ * few GB of compressor on a busy machine is routine. It is the combination — the
111
+ * compressor has eaten a large slice of RAM *and* there is nothing left to hand out
112
+ * — that means the VM system is about to stall. Swap is reported for context and as
113
+ * a corroborating trigger, not on its own: a Mac can sit on GBs of swap indefinitely
114
+ * while perfectly healthy.
115
+ */
116
+ export declare function assessMemoryPressure(vm: VmPressure, opts: {
117
+ totalRamMb: number;
118
+ compressorPct?: number;
119
+ freeFloorMb?: number;
120
+ swapFloorMb?: number;
121
+ }): {
122
+ level: 'ok' | 'fail';
123
+ reason: string;
124
+ };
125
+ /**
126
+ * Check the machine before adding load to it.
127
+ *
128
+ * Returns **true when it is safe to proceed**. It never calls `process.exit` — this is
129
+ * library code (`buildSite`/`devServer` are public exports of `tosijs-ui/site`), and an
130
+ * adopter's `await buildSite(cfg); await publishToS3()` must not be killed from inside
131
+ * a health check it did not ask for. The caller decides what a `false` means.
132
+ *
133
+ * Modes (`mode`, or `preflight` in SiteConfig):
134
+ * 'fail' — refuse (return false). Auto-downgraded to 'warn' in CI / non-TTY.
135
+ * 'warn' — print and proceed.
136
+ * 'off' — skip entirely. Also `DEV_SKIP_PREFLIGHT=1`.
137
+ *
138
+ * Best-effort by construction: `ps` is POSIX-only, and a health check that breaks the
139
+ * build when IT fails is worse than no health check at all.
140
+ */
141
+ export type PreflightMode = 'off' | 'warn' | 'fail';
142
+ export declare function preflight(opts?: {
143
+ devLimitMb?: number;
144
+ label?: string;
145
+ mode?: PreflightMode | false;
146
+ /** injected for tests; defaults to a real `ps` snapshot */
147
+ procs?: ProcInfo[];
148
+ /** injected for tests; defaults to this machine's physical RAM */
149
+ totalRamMb?: number;
150
+ /** injected for tests; defaults to a real vm_stat/swapusage read (null off darwin) */
151
+ vm?: VmPressure | null;
152
+ }): Promise<boolean>;