tosijs-ui 1.6.21 → 1.6.22

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,31 +12,80 @@ 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';
15
+ import { existsSync, mkdirSync, unlinkSync } from 'fs';
16
+ import { tmpdir } from 'os';
16
17
  import { gzipSync } from 'zlib';
17
- import { $ } from 'bun';
18
+ import { $, spawn } from 'bun';
18
19
  import { extractDocs } from './docs';
19
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
25
  // Module specifiers contain regex metacharacters (`/`, `.`, `@`, …), so escape
26
26
  // before interpolating into the require-shim detector below.
27
- 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) {
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);
32
70
  try {
33
- Bun.resolveSync('tjs-lang/editors/codemirror', root);
34
- return [];
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
+ }
35
75
  }
36
- catch {
37
- return ['tjs-lang/editors/codemirror'];
76
+ finally {
77
+ clearTimeout(killer);
78
+ try {
79
+ unlinkSync(payloadPath);
80
+ }
81
+ catch {
82
+ // already gone — fine
83
+ }
38
84
  }
39
85
  }
86
+ // Module specifiers contain regex metacharacters (`/`, `.`, `@`, …), so escape
87
+ // before interpolating into the require-shim detector below.
88
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
40
89
  export async function buildSite(config) {
41
90
  const PROJECT_ROOT = './';
42
91
  const PUBLIC = path.resolve(PROJECT_ROOT, config.outputDir ?? 'docs');
@@ -142,36 +191,47 @@ export async function buildSite(config) {
142
191
  // consumer supplies (e.g. via staticDirs or an absolute URL).
143
192
  const scriptName = (config.scriptUrl ?? '/iife.js').replace(/^\//, '');
144
193
  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) {
194
+ // tjs-lang transpiles live examples; it's dynamically import()'d at
195
+ // runtime (falling back to CDN), so keep it out of the bundle.
196
+ const externals = [
197
+ 'tjs-lang',
198
+ 'tjs-lang/browser',
199
+ 'tjs-lang/browser/from-ts',
200
+ ...(config.bundleExternals ?? []),
201
+ ];
202
+ // Bundle in a CHILD PROCESS, not via Bun.build().
203
+ //
204
+ // Bun.build() never gives back the bundler's native arena: measured at ~9MB
205
+ // of RSS per call and rising, monotonic, with no plateau (40 sequential
206
+ // builds of one real entry = +367MB, still climbing ~5MB/build at the end),
207
+ // while the JS heap stays flat — so it is invisible to Bun.gc() and to any
208
+ // heap profiler. devServer() calls this once per rebuild in a process that
209
+ // lives for DAYS, so it compounds: a ~2-day watch session reached 136GB RSS
210
+ // and took the machine down with it. Filed as oven-sh/bun#34053.
211
+ //
212
+ // The CLI does identical work in a child whose memory the OS reclaims on
213
+ // exit: the same 15 bundles leave the parent +0.5MB instead of +192MB.
214
+ // Keep these flags in sync with the Bun.build() options they replace.
215
+ const bundle = spawn([
216
+ 'bun',
217
+ 'build',
218
+ config.bundleEntry,
219
+ '--outdir',
220
+ DIST,
221
+ '--sourcemap=linked',
222
+ '--format=iife',
223
+ '--minify',
224
+ '--entry-naming',
225
+ scriptName,
226
+ ...externals.flatMap((ext) => ['--external', ext]),
227
+ ], { stdout: 'inherit', stderr: 'inherit' });
228
+ if ((await bundle.exited) !== 0) {
170
229
  console.error('bundle build failed');
171
- for (const message of result.logs)
172
- console.error(message);
173
230
  return false;
174
231
  }
232
+ await $ `cp ${DIST}/${scriptName} ${PUBLIC}`.text();
233
+ const bundleFile = await Bun.file(`${DIST}/${scriptName}`).arrayBuffer();
234
+ const bundleJs = Buffer.from(bundleFile).toString('utf8');
175
235
  // Warn only when an external actually compiled to a synchronous require()
176
236
  // shim, which throws at module-eval ("Dynamic require of … is not
177
237
  // supported"). That only happens for a *static* `import x from 'ext'`. A
@@ -180,20 +240,15 @@ export async function buildSite(config) {
180
240
  // stay silent. The config alone can't tell the two apart; the emitted
181
241
  // bundle can, so we inspect the actual output.
182
242
  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));
243
+ const broken = config.bundleExternals.filter((ext) => new RegExp(`(?:__require|\\brequire)\\(\\s*["'\`]${escapeRegExp(ext)}["'\`]`).test(bundleJs));
187
244
  if (broken.length > 0) {
188
245
  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
246
  ` or an importmap, not a static import.`);
190
247
  }
191
248
  }
192
- await $ `cp ${DIST}/${scriptName} ${PUBLIC}`.text();
193
- const bundleFile = await Bun.file(`${DIST}/${scriptName}`).arrayBuffer();
194
249
  // import.meta is illegal in a classic <script> — if it survived bundling
195
250
  // (a branch the bundler couldn't eliminate) the IIFE will SyntaxError.
196
- if (Buffer.from(bundleFile).toString('utf8').includes('import.meta')) {
251
+ if (bundleJs.includes('import.meta')) {
197
252
  console.warn(`⚠️ ${scriptName} contains \`import.meta\`, which is a SyntaxError in a classic <script>.\n` +
198
253
  ` A dependency referenced it in a branch the bundler couldn't drop — mark that dep external\n` +
199
254
  ` (+ importmap) or choose a browser-only entry point.`);
@@ -319,7 +374,7 @@ export async function buildSite(config) {
319
374
  // the corpus and survives the `rm -rf <outputDir>` at the top of the NEXT build
320
375
  // (otherwise a dev rebuild would silently drop it). Cheap (~0.4s).
321
376
  if (config.epub) {
322
- await buildEpub(config, typeof config.epub === 'object' ? config.epub : {});
377
+ await buildEpubInChild(config, typeof config.epub === 'object' ? config.epub : {});
323
378
  }
324
379
  console.timeEnd('build');
325
380
  return true;
@@ -186,6 +186,16 @@ export interface SiteConfig {
186
186
  port?: number;
187
187
  /** extra dev-server watch paths (added to docPaths + bundleEntry dir). */
188
188
  watchPaths?: string[];
189
+ /**
190
+ * RSS ceiling (MB) for the dev server, default 4096. A watch process lives for
191
+ * days across thousands of rebuilds, so anything the build strands per rebuild
192
+ * compounds until the machine swaps itself to death. Past this, the server
193
+ * prints the growth-per-rebuild and exits rather than take the machine with it.
194
+ * Overridden by the DEV_MEMORY_LIMIT_MB env var. Raise it if a genuinely large
195
+ * build needs the headroom — but sustained growth per rebuild is a leak, not a
196
+ * ceiling that's too low.
197
+ */
198
+ memoryLimitMb?: number;
189
199
  /**
190
200
  * Enable the dev-server source read/write endpoints (`/__docstore/source`) that
191
201
  * back in-browser "edit page source". Local dev only — the dev server runs on