tosijs-ui 1.6.20 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.22
4
+
5
+ ### Fixed
6
+
7
+ - **The dev server no longer leaks memory on every rebuild — update as a priority if
8
+ you use `tosijs-ui/site`.** `buildSite()` called `Bun.build()` in-process, and Bun's
9
+ bundler never returns its native arena: RSS grows monotonically per call with no
10
+ plateau (40 sequential builds of one entry = **+367MB**, still climbing ~5MB/build at
11
+ the end), while the JS heap stays flat — so it is invisible to `Bun.gc()` and to any
12
+ heap profiler. `devServer()` calls it once per rebuild in a process that runs for
13
+ days, so it compounds: a ~2-day watch session reached **136GB RSS** and took the
14
+ machine down with it. Filed upstream as
15
+ [oven-sh/bun#34053](https://github.com/oven-sh/bun/issues/34053).
16
+
17
+ The bundle now builds in a **child process** (`bun build`), whose memory the OS
18
+ reclaims on exit — the same 15 bundles leave the parent **+0.5MB** instead of +192MB.
19
+ `buildEpub()` moved to a child too (happy-dom + `@resvg/resvg-js` are both native and
20
+ retaining, and it runs on every rebuild), with a 120s timeout kill so a hung or failed
21
+ ePub warns instead of wedging the rebuild. Measured on this repo's own dev server:
22
+ **baseline RSS 503MB → 150MB, and per-rebuild growth 26–59MB → ~2.7MB.**
23
+
24
+ Bundle output is byte-for-byte identical; this is purely where the work runs.
25
+
26
+ ### Added
27
+
28
+ - **Dev-server memory watchdog.** `devServer()` now samples RSS after each rebuild and,
29
+ past a ceiling (`memoryLimitMb` in the site config, or `DEV_MEMORY_LIMIT_MB`; default
30
+ 4096), prints the growth-per-rebuild and exits rather than let a leak thrash the
31
+ machine. It distinguishes real growth (a leak — report it) from a build whose baseline
32
+ simply exceeds the ceiling (raise the ceiling).
33
+
34
+ ## 1.6.21
35
+
36
+ - Memory-routed nav-toggle fix; `buildSite` `.tjs` hooks (`libraryBuild`,
37
+ `generateCssPreload` — see BUILD-TJS-HOOK.md); tjs-lang 0.9.0.
38
+
3
39
  ## 1.6.20
4
40
 
5
41
  ### Fixed
@@ -1,15 +1,7 @@
1
- import { Component as WebComponent, ElementCreator, PartsMap } from 'tosijs';
2
- import type { CmHandle } from './code-editor-cm';
3
- interface CodeEditorParts extends PartsMap {
4
- host: HTMLDivElement;
5
- }
6
- export declare class CodeEditor extends WebComponent<CodeEditorParts> {
1
+ import { Component as WebComponent, ElementCreator } from 'tosijs';
2
+ export declare class CodeEditor extends WebComponent {
7
3
  static preferredTagName: string;
8
4
  private source;
9
- private _handle;
10
- private _loadPromise;
11
- private _appliedMode;
12
- private _appliedDisabled;
13
5
  get value(): string;
14
6
  set value(text: string);
15
7
  private _original;
@@ -20,16 +12,16 @@ export declare class CodeEditor extends WebComponent<CodeEditorParts> {
20
12
  showDiff(on: boolean): void;
21
13
  static initAttributes: {
22
14
  mode: string;
15
+ theme: string;
23
16
  disabled: boolean;
24
17
  };
25
18
  role: string;
26
- /** The underlying CodeMirror EditorView (undefined until loaded). */
27
- get editor(): CmHandle['view'] | undefined;
28
- undo(): void;
29
- redo(): void;
30
- canUndo(): boolean;
31
- canRedo(): boolean;
32
- content: () => HTMLDivElement[];
19
+ private _ace;
20
+ private _editor;
21
+ private _editorPromise;
22
+ options: any;
23
+ get ace(): any;
24
+ get editor(): any;
33
25
  static shadowStyleSpec: {
34
26
  ':host': {
35
27
  display: string;
@@ -37,20 +29,9 @@ export declare class CodeEditor extends WebComponent<CodeEditorParts> {
37
29
  width: string;
38
30
  height: string;
39
31
  };
40
- '[part="host"]': {
41
- height: string;
42
- };
43
- '.cm-editor': {
44
- height: string;
45
- };
46
- '.cm-scroller': {
47
- outline: string;
48
- fontFamily: string;
49
- };
50
32
  };
51
33
  onResize(): void;
52
34
  connectedCallback(): void;
53
35
  render(): void;
54
36
  }
55
37
  export declare const codeEditor: ElementCreator<CodeEditor>;
56
- export {};
@@ -1,12 +1,12 @@
1
1
  /*#
2
2
  # code
3
3
 
4
- A [CodeMirror 6](https://codemirror.net/) wrapper.
4
+ An [ACE Editor](https://ace.c9.io/) wrapper.
5
5
 
6
6
  Sometimes, it's nice to be able to just toss a code-editor in a web-page.
7
7
 
8
- `<tosi-code>`'s `value` is the code it contains. Its `mode` attribute sets the
9
- language (`javascript`, `typescript`, `tjs`, `css`, `html`, `markdown`).
8
+ `<tosi-code>`'s `value` is the code it contains. Its `mode` attribute sets the language, and you can further configure
9
+ the ACE editor instance via its `options` property.
10
10
 
11
11
  ```html
12
12
  <tosi-code style="width: 100%; height: 100%" mode="css">
@@ -16,33 +16,51 @@ body {
16
16
  </tosi-code>
17
17
  ```
18
18
 
19
- The `<tosi-code>` element has an `editor` property that gives you its CodeMirror
20
- [`EditorView`](https://codemirror.net/docs/ref/#view.EditorView), and
21
- `undo()` / `redo()` / `canUndo()` / `canRedo()` methods for history control.
22
-
23
- CodeMirror is loaded lazily on first use — a page with no `<tosi-code>` bundles
24
- none of it.
19
+ The `<tosi-code>` element has an `editor` property that gives you its ACE editor instance,
20
+ and an `ace` property that returns the `ace` module, giving you complete access to the
21
+ [Ace API](https://ace.c9.io/api/index.html).
25
22
  */
26
23
  /*{ "parent": "Components" }*/
27
- import { Component as WebComponent, elements } from 'tosijs';
24
+ import { Component as WebComponent } from 'tosijs';
25
+ import { scriptTag } from './via-tag';
28
26
  import { tosiDiff } from './diff';
29
- const { div } = elements;
27
+ const ACE_BASE_URL = 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.23.2/';
28
+ const DEFAULT_THEME = 'ace/theme/tomorrow';
29
+ const getAce = async () => {
30
+ const { ace } = await scriptTag(`${ACE_BASE_URL}ace.min.js`);
31
+ return ace;
32
+ };
33
+ const makeCodeEditor = async (codeElement, mode = 'html', options = {}, theme = DEFAULT_THEME) => {
34
+ const ace = await getAce();
35
+ ace.config.set('basePath', ACE_BASE_URL);
36
+ const editor = ace.edit(codeElement, {
37
+ mode: `ace/mode/${mode}`,
38
+ tabSize: 2,
39
+ useSoftTabs: true,
40
+ useWorker: false,
41
+ // Wrap wide lines instead of scrolling horizontally; indentedSoftWrap keeps
42
+ // wrapped continuations aligned under the line's indent.
43
+ wrap: true,
44
+ indentedSoftWrap: true,
45
+ ...options,
46
+ });
47
+ editor.setTheme(theme);
48
+ return { ace, editor };
49
+ };
30
50
  export class CodeEditor extends WebComponent {
31
51
  static preferredTagName = 'tosi-code';
32
52
  source = '';
33
- _handle;
34
- _loadPromise;
35
- _appliedMode = '';
36
- _appliedDisabled;
37
53
  get value() {
38
- return this._handle ? this._handle.getValue() : this.source;
54
+ return this.editor === undefined ? this.source : this.editor.getValue();
39
55
  }
40
56
  set value(text) {
41
- if (this._handle) {
42
- this._handle.setValue(text);
57
+ if (this.editor === undefined) {
58
+ this.source = text;
43
59
  }
44
60
  else {
45
- this.source = text;
61
+ this.editor.setValue(text);
62
+ this.editor.clearSelection();
63
+ this.editor.session.getUndoManager().reset();
46
64
  }
47
65
  }
48
66
  // Baseline for `showDiff()` — the version to diff the current `value` against.
@@ -54,9 +72,9 @@ export class CodeEditor extends WebComponent {
54
72
  set original(text) {
55
73
  this._original = text;
56
74
  }
57
- // Diff overlay — built only on the editor's public surface (`value` + `original`)
58
- // and the tosi-diff component, never the underlying editor's API, so it is
59
- // editor-agnostic (this is why it survived the Ace → CodeMirror swap untouched).
75
+ // Diff overlay — deliberately built only on the editor's public surface
76
+ // (`value` + `original`) and the tosi-diff component, never the underlying
77
+ // editor's API, so it survives a future Ace → CodeMirror swap untouched.
60
78
  diffOverlay;
61
79
  get showingDiff() {
62
80
  return this.diffOverlay !== undefined && !this.diffOverlay.hidden;
@@ -85,27 +103,20 @@ export class CodeEditor extends WebComponent {
85
103
  }
86
104
  static initAttributes = {
87
105
  mode: 'javascript',
106
+ theme: DEFAULT_THEME,
88
107
  disabled: false,
89
108
  };
90
109
  role = 'code editor';
91
- /** The underlying CodeMirror EditorView (undefined until loaded). */
92
- get editor() {
93
- return this._handle?.view;
94
- }
95
- // History control — so consumers use these instead of reaching into `editor`.
96
- undo() {
97
- this._handle?.undo();
98
- }
99
- redo() {
100
- this._handle?.redo();
101
- }
102
- canUndo() {
103
- return this._handle?.canUndo() ?? false;
110
+ _ace;
111
+ _editor;
112
+ _editorPromise;
113
+ options = {};
114
+ get ace() {
115
+ return this._ace;
104
116
  }
105
- canRedo() {
106
- return this._handle?.canRedo() ?? false;
117
+ get editor() {
118
+ return this._editor;
107
119
  }
108
- content = () => [div({ part: 'host' })];
109
120
  static shadowStyleSpec = {
110
121
  ':host': {
111
122
  display: 'block',
@@ -113,49 +124,32 @@ export class CodeEditor extends WebComponent {
113
124
  width: '100%',
114
125
  height: '100%',
115
126
  },
116
- '[part="host"]': { height: '100%' },
117
- '.cm-editor': { height: '100%' },
118
- '.cm-scroller': {
119
- outline: 'none',
120
- fontFamily: "Menlo, Monaco, Consolas, 'Courier New', monospace",
121
- },
122
127
  };
123
128
  onResize() {
124
- this._handle?.refresh();
129
+ if (this.editor !== undefined) {
130
+ this.editor.resize(true);
131
+ }
125
132
  }
126
133
  connectedCallback() {
127
134
  super.connectedCallback();
128
135
  if (this.source === '') {
129
136
  this.value = this.textContent !== null ? this.textContent.trim() : '';
130
137
  }
131
- if (this._loadPromise === undefined) {
132
- // Lazy chunk — CodeMirror only enters the bundle here, on first editor use.
133
- this._loadPromise = import('./code-editor-cm').then(({ createCmEditor }) => {
134
- const handle = createCmEditor(this.parts.host, {
135
- value: this.source,
136
- mode: this.mode,
137
- readOnly: this.disabled,
138
- root: this.shadowRoot ?? undefined,
139
- onChange: (value) => this.dispatchEvent(new CustomEvent('change', { detail: { value } })),
140
- });
141
- this._handle = handle;
142
- this._appliedMode = this.mode;
143
- this._appliedDisabled = this.disabled;
144
- return handle;
138
+ if (this._editorPromise === undefined) {
139
+ this._editorPromise = makeCodeEditor(this, this.mode, this.options, this.theme);
140
+ this._editorPromise.then(({ ace, editor }) => {
141
+ this._ace = ace;
142
+ this._editor = editor;
143
+ editor.setValue(this.source, 1);
144
+ editor.clearSelection();
145
+ editor.session.getUndoManager().reset();
145
146
  });
146
147
  }
147
148
  }
148
149
  render() {
149
150
  super.render();
150
- if (this._handle) {
151
- if (this.disabled !== this._appliedDisabled) {
152
- this._handle.setReadOnly(this.disabled);
153
- this._appliedDisabled = this.disabled;
154
- }
155
- if (this.mode !== this._appliedMode) {
156
- this._handle.setMode(this.mode);
157
- this._appliedMode = this.mode;
158
- }
151
+ if (this._editorPromise !== undefined) {
152
+ this._editorPromise.then(({ editor }) => editor.setReadOnly(this.disabled));
159
153
  }
160
154
  }
161
155
  }
@@ -472,6 +472,11 @@ export function createDocBrowser(options) {
472
472
  }
473
473
  });
474
474
  }
475
+ // This instance's own sidenav. Captured from its own change event (below), so the
476
+ // header nav toggle drives THIS doc-browser — not `document.querySelector('tosi-
477
+ // sidenav')`, which returns the FIRST sidenav in the document (an OUTER instance
478
+ // when this one is an embedded, memory-routed browser).
479
+ let sidenav = null;
475
480
  const headerContent = [
476
481
  button({
477
482
  class: 'iconic',
@@ -489,8 +494,8 @@ export function createDocBrowser(options) {
489
494
  },
490
495
  },
491
496
  onClick() {
492
- const nav = document.querySelector(TosiSidenav.tagName);
493
- nav.contentVisible = !nav.contentVisible;
497
+ if (sidenav)
498
+ sidenav.contentVisible = !sidenav.contentVisible;
494
499
  },
495
500
  }, icons.menu()),
496
501
  span({ style: { flex: '0 0 10px' } }),
@@ -1027,9 +1032,12 @@ export function createDocBrowser(options) {
1027
1032
  flex: '1 1 auto',
1028
1033
  overflow: 'hidden',
1029
1034
  },
1030
- onChange() {
1031
- const nav = document.querySelector(TosiSidenav.tagName);
1032
- app.compact = nav.compact;
1035
+ onChange(event) {
1036
+ // Scope to the sidenav that fired — and capture it so the header toggle
1037
+ // targets THIS instance, not the first sidenav in the document.
1038
+ sidenav = event.target.closest(TosiSidenav.tagName);
1039
+ if (sidenav)
1040
+ app.compact = sidenav.compact;
1033
1041
  },
1034
1042
  }, searchField, navContent, div({
1035
1043
  style: {
@@ -223,6 +223,57 @@ export async function devServer(config, opts = {}) {
223
223
  // never regenerated — leaving the page's /iife.js to 404 into the SPA
224
224
  // fallback (it "loads as html"). Serialize builds and coalesce bursts.
225
225
  const runBuild = opts.build ?? (() => buildSite(config));
226
+ // Memory watchdog.
227
+ //
228
+ // The build hands work to native code (the bundler, the HTML parser, the SVG
229
+ // rasterizer) which can strand memory the JS heap never sees — so nothing
230
+ // here can GC it away, and `heapUsed` stays flat while RSS climbs. A watch
231
+ // process lives for DAYS across thousands of rebuilds, so a per-rebuild leak
232
+ // compounds until the machine swaps itself to death. That is not
233
+ // hypothetical: a 2-day session reached 136GB RSS and took the machine down
234
+ // (Bun.build's native arena — see orchestrator.ts and oven-sh/bun#34053).
235
+ // Dying loudly beats being the reason a laptop overheats: the dev server is
236
+ // one keystroke to restart, the machine is not.
237
+ const rssMb = () => Math.round(process.memoryUsage().rss / 1e6);
238
+ const limitMb = Number(process.env.DEV_MEMORY_LIMIT_MB ?? config.memoryLimitMb ?? 4096);
239
+ let baselineMb = 0;
240
+ let rebuilds = 0;
241
+ let warned = false;
242
+ const checkMemory = () => {
243
+ const mb = rssMb();
244
+ rebuilds += 1;
245
+ if (!baselineMb)
246
+ baselineMb = mb;
247
+ const growth = mb - baselineMb;
248
+ const each = rebuilds > 1 ? growth / (rebuilds - 1) : 0;
249
+ if (mb >= limitMb) {
250
+ // Distinguish the two ways to get here, because the advice is opposite:
251
+ // memory that GREW across rebuilds is a leak (report it); a build that was
252
+ // simply born bigger than the ceiling just needs a bigger ceiling.
253
+ const leaking = rebuilds > 1 && each >= 1;
254
+ const diagnosis = leaking
255
+ ? ` ${rebuilds} rebuilds, +${growth}MB since the first ` +
256
+ `(~${each.toFixed(1)}MB per rebuild).\n\n` +
257
+ ` Growth per rebuild should be ~0, so this is a leak, not your project\n` +
258
+ ` getting bigger. Restarting reclaims it — please report the numbers\n` +
259
+ ` above. If this build genuinely needs the headroom, raise the ceiling\n` +
260
+ ` with DEV_MEMORY_LIMIT_MB=<mb> or memoryLimitMb in your site config.\n`
261
+ : ` ${rebuilds} rebuild${rebuilds === 1 ? '' : 's'}, ` +
262
+ `+${growth}MB since the first — i.e. it is not growing.\n\n` +
263
+ ` This build's baseline footprint is simply above the ceiling, which is\n` +
264
+ ` not a leak. Raise it with DEV_MEMORY_LIMIT_MB=<mb> or memoryLimitMb in\n` +
265
+ ` your site config.\n`;
266
+ console.error(`\n🛑 dev server stopping: ${mb}MB RSS, over the ${limitMb}MB limit.\n\n` +
267
+ diagnosis);
268
+ process.exit(1);
269
+ }
270
+ if (!warned && mb > limitMb * 0.6) {
271
+ warned = true;
272
+ console.warn(`⚠️ dev server at ${mb}MB RSS after ${rebuilds} rebuilds (+${growth}MB, ` +
273
+ `~${each.toFixed(1)}MB each; limit ${limitMb}MB). Growth per rebuild ` +
274
+ `should be ~0 — if this keeps climbing, restart and report it.`);
275
+ }
276
+ };
226
277
  let building = false;
227
278
  let pending = false;
228
279
  const rebuild = async () => {
@@ -239,6 +290,7 @@ export async function devServer(config, opts = {}) {
239
290
  }
240
291
  finally {
241
292
  building = false;
293
+ checkMemory();
242
294
  if (pending) {
243
295
  pending = false;
244
296
  void rebuild();
@@ -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
  }
@@ -1,5 +1,5 @@
1
1
  export { defineSiteConfig } from './site-config';
2
- export type { SiteConfig, SiteHost } from './site-config';
2
+ export type { SiteConfig, SiteHost, LibraryBuildContext } from './site-config';
3
3
  export { buildSite } from './orchestrator';
4
4
  export { devServer } from './dev-server';
5
5
  export { extractDocs, saveDocsJSON } from './docs';