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.
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,27 +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
- private isTjsMode;
54
- /**
55
- * When in tjs mode, lazily upgrade the editor to tjs-lang's CodeMirror language +
56
- * autocomplete. No-op (keeps TS highlighting) if not tjs, if tjs-lang isn't
57
- * installed, or if the mode/handle changed before the async load resolved.
58
- */
59
- private applyTjsExtension;
60
35
  render(): void;
61
36
  }
62
37
  export declare const codeEditor: ElementCreator<CodeEditor>;
63
- 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;
110
+ _ace;
111
+ _editor;
112
+ _editorPromise;
113
+ options = {};
114
+ get ace() {
115
+ return this._ace;
94
116
  }
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;
104
- }
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,71 +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
- this.applyTjsExtension();
145
- 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();
146
146
  });
147
147
  }
148
148
  }
149
- isTjsMode() {
150
- return this.mode === 'tjs' || this.mode === 'ajs';
151
- }
152
- /**
153
- * When in tjs mode, lazily upgrade the editor to tjs-lang's CodeMirror language +
154
- * autocomplete. No-op (keeps TS highlighting) if not tjs, if tjs-lang isn't
155
- * installed, or if the mode/handle changed before the async load resolved.
156
- */
157
- applyTjsExtension() {
158
- const handle = this._handle;
159
- if (!handle || !this.isTjsMode())
160
- return;
161
- import('./code-editor-cm')
162
- .then(({ loadTjsExtension }) => loadTjsExtension())
163
- .then((ext) => {
164
- if (ext && this._handle === handle && this.isTjsMode()) {
165
- handle.setLanguageExtension(ext);
166
- }
167
- });
168
- }
169
149
  render() {
170
150
  super.render();
171
- if (this._handle) {
172
- if (this.disabled !== this._appliedDisabled) {
173
- this._handle.setReadOnly(this.disabled);
174
- this._appliedDisabled = this.disabled;
175
- }
176
- if (this.mode !== this._appliedMode) {
177
- this._handle.setMode(this.mode);
178
- this._appliedMode = this.mode;
179
- this.applyTjsExtension();
180
- }
151
+ if (this._editorPromise !== undefined) {
152
+ this._editorPromise.then(({ editor }) => editor.setReadOnly(this.disabled));
181
153
  }
182
154
  }
183
155
  }
@@ -48,10 +48,10 @@ async function ensureHaltijaChannel(port) {
48
48
  // HTTPS dev page has no mixed-content), while the `hj` CLI drives over its
49
49
  // default HTTP 8700 — one server, both transports, shared state. HTTPS cert
50
50
  // is mkcert-trusted. Output quiet so it doesn't drown the dev log.
51
- // `@latest` (1.3.2+): the widget-mode WebRTC screen capture (which makes the
52
- // Electron app CI-only) is on the stable channel now, along with the fix for
53
- // an accidental Electron launch. (Was pinned to `@beta` before 1.3.2.)
54
- spawn(['bunx', 'haltija@latest', '--server', '--both'], {
51
+ // Track the `@beta` tag: in-browser (widget-mode) WebRTC screen capture —
52
+ // which makes the Electron app CI-only — landed in the beta channel, ahead
53
+ // of the `latest` dist-tag.
54
+ spawn(['bunx', 'haltija@beta', '--server', '--both'], {
55
55
  stdout: 'ignore',
56
56
  stderr: 'ignore',
57
57
  });
@@ -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
  }
@@ -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
  ];