tosijs-ui 1.9.5 → 1.9.6

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,82 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.6
4
+
5
+ **`<tosi-table>` sorts correctly** (#62, reported by snowfox). Two independent defects, one
6
+ in each half of the column comparator.
7
+
8
+ **Numeric strings sorted by first digit.** The comparator used a raw `>`, which compares
9
+ strings lexically — so `'9'` sorted after `'399'`, and `'1200'` before `'3.5'`. Real data
10
+ is full of numeric strings: CSV and TSV imports, BigQuery exports, JSON where numbers
11
+ arrived as text, anything id-shaped. Sorting now compares naturally:
12
+
13
+ - numeric values (including numeric strings) compare as numbers, decimals and negatives
14
+ included
15
+ - very long integers stay exact — two 20-digit ids differing in the last digit order
16
+ correctly, where a plain `parseFloat` comparison calls them equal
17
+ - text compares with a locale collator, so accented letters order the way the reader
18
+ expects rather than the way ASCII does
19
+ - blank cells sort **last in both directions** — a descending sort that opens on a
20
+ screenful of empty cells is never what was clicked for
21
+
22
+ The old comparator also never returned `0`, so two *equal* values each claimed to be
23
+ greater than the other. `Array.sort` is entitled to turn an inconsistent comparator into
24
+ arbitrary output, not merely wrong output.
25
+
26
+ **New `ColumnOptions.sortValue`** — sort by what the cell *shows*, when that differs from
27
+ what the row stores. A column with a custom `dataCell` renders whatever it likes while the
28
+ sort keyed on `prop`, so the rows reordered by an invisible value:
29
+
30
+ ```typescript
31
+ {
32
+ name: 'Invoice #',
33
+ prop: 'Customer invoice ID', // what the row stores — CSV export, lookups
34
+ dataCell: invoiceCell, // what the reader sees
35
+ sortValue: (row) => row['Invoice number'] || row['Customer invoice ID'],
36
+ }
37
+ ```
38
+
39
+ Purely additive; `table.sort` still overrides everything for cases a per-column value
40
+ cannot express.
41
+
42
+ **New `naturalCompare` / `naturalSorter` / `isBlank`** exported from `tosijs-ui`, since the
43
+ same comparison is wanted outside tables.
44
+
45
+ ### Build system
46
+
47
+ - **Code-split chunks land in `_chunks/` instead of the web root** (#64). `--splitting`
48
+ emits one hashed chunk per dynamic import, flat beside `index.html` — for a corpus that
49
+ pulls something large, that is thousands of files: tosijs-3d reported **2,473 hashed
50
+ chunks among 2,604 files**, all tracked in git because `docs/` is the Pages source.
51
+
52
+ The cost that hurts is not legibility. Any dependency bump rewrites every hash, so
53
+ upgrading one package became a multi-thousand-file commit — which makes the standing
54
+ "never commit `docs/` from a feature push" hazard far worse, since a stray `git add -A`
55
+ moves thousands of files and the diff is unreviewable. Assets move to `_assets/` for the
56
+ same reason.
57
+
58
+ - **The dev server no longer times out mid-transfer on LAN loads** (#63). `Bun.serve`
59
+ defaults to a 10s `idleTimeout`, sized for small API responses — but a doc site's bundle
60
+ can be multiple MB, and over wifi to a phone or a second laptop that legitimately takes
61
+ longer, so the connection closed part-way. It never reproduces on loopback, which makes
62
+ it look like a client-side stall. Now 120s, overridable with
63
+ `DEV_REQUEST_TIMEOUT_SECONDS`. Testing on real devices over the LAN is exactly what the
64
+ dev cert covers `<host>.local` for.
65
+
66
+ ### Also
67
+
68
+ - **The release lane now checks, against the packed manifest, that each peer dependency we
69
+ also develop against is pinned within the range we publish** (#57). A floor the library
70
+ is not itself built against is a contract nobody tests, and the only signal was an
71
+ adopter's install warning. `tosijs`'s `^1.7.8` floor is not arbitrary — it encodes
72
+ tosijs#20 (the `parts` proxy crossing into nested components) and #21 — and that reason
73
+ is now recorded in `CLAUDE.md` rather than only in an old changelog entry.
74
+
75
+ `makeSorter` deliberately keeps its existing comparison: switching it would change *case*
76
+ ordering (`'Zed'` before `'alice'` today) in a general-purpose utility where that has not
77
+ been reported as a problem. Coerce numeric strings in the valuator, or pass values through
78
+ `naturalCompare` yourself.
79
+
3
80
  ## 1.9.5
4
81
 
5
82
  **`localize()` no longer destroys literal `#`** (#55, reported by snowfox).
@@ -10,6 +10,23 @@ export interface ColumnOptions {
10
10
  type?: ValueRendererType;
11
11
  pinned?: 'left' | 'right';
12
12
  sort?: false | 'ascending' | 'descending';
13
+ /**
14
+ * What this column SORTS by, when that differs from what it stores.
15
+ *
16
+ * Defaults to `row[prop]`. A column with a custom `dataCell` renders whatever it likes
17
+ * while the sort keys on `prop` — so when those differ, clicking "Sort Ascending"
18
+ * reorders rows by a value the reader cannot see, which reads as "sorting is broken"
19
+ * rather than "sorting a different field" (tosijs-ui#62).
20
+ *
21
+ * { name: 'Invoice #', prop: 'Customer invoice ID', dataCell: invoiceCell,
22
+ * sortValue: (row) => row['Invoice number'] || row['Customer invoice ID'] }
23
+ *
24
+ * Every other escape hatch costs more: `table.sort` is table-wide (one derived column
25
+ * means reimplementing sorting for all of them), replacing `headerCell` means
26
+ * reimplementing the header menu, and changing `prop` breaks CSV export and anything
27
+ * else keyed on it.
28
+ */
29
+ sortValue?: (row: any) => unknown;
13
30
  headerCell?: (options: ColumnOptions) => HTMLElement;
14
31
  dataCell?: (options: ColumnOptions) => HTMLElement;
15
32
  }
@@ -566,6 +566,7 @@ As well as any column names you want localized.
566
566
  /*{ "parent": "Components" }*/
567
567
  import { Component as WebComponent, elements, vars, varDefault, tosiValue, getListItem, getListBinding, tosi, } from 'tosijs';
568
568
  import { trackDrag } from './track-drag.js';
569
+ import { naturalSorter } from './natural-compare.js';
569
570
  import { icons } from './icons.js';
570
571
  import { valueRenderer } from './value-renderer.js';
571
572
  import { popMenu } from './menu.js';
@@ -889,10 +890,19 @@ export class TosiTable extends WebComponent {
889
890
  if (!sortColumn) {
890
891
  return undefined;
891
892
  }
892
- const { prop } = sortColumn;
893
- return sortColumn.sort === 'ascending'
894
- ? (a, b) => (a[prop] > b[prop] ? 1 : -1)
895
- : (a, b) => (a[prop] > b[prop] ? -1 : 1);
893
+ /*
894
+ Two defects lived in these three lines (tosijs-ui#62).
895
+
896
+ It keyed on `prop`, so a column whose `dataCell` shows something else sorted by a value
897
+ the reader cannot see. And `>` is a LEXICAL compare, so any column of numeric strings
898
+ sorted by first digit — `'9' > '399'` is true, and real data is full of numeric strings
899
+ from CSV, BigQuery and JSON. Worse, `a > b ? 1 : -1` never returns 0, so two equal
900
+ values each claimed to be greater than the other; `Array.sort` is entitled to turn an
901
+ inconsistent comparator into arbitrary output rather than merely wrong output.
902
+ */
903
+ const { prop, sortValue } = sortColumn;
904
+ const key = sortValue ?? ((row) => row[prop]);
905
+ return naturalSorter(key, sortColumn.sort === 'ascending');
896
906
  }
897
907
  set sort(sortFunc) {
898
908
  if (this._sort !== sortFunc) {
@@ -68,6 +68,10 @@ export declare function resolveLimitMb(configMb: number | undefined, envMb: stri
68
68
  export { isLoopbackAddress };
69
69
  export declare function haltijaIsDrivable(stdout: string): boolean;
70
70
  export declare function haltijaLoaderSnippet(httpsPort: number): string;
71
+ /** br if the client takes it, else gzip, else nothing. */
72
+ export declare function negotiateEncoding(accept: string | null): 'br' | 'gzip' | null;
73
+ /** Is this worth compressing? Already-compressed formats only get bigger. */
74
+ export declare function isCompressible(filePath: string): boolean;
71
75
  export declare function devServer(config: SiteConfig, opts?: {
72
76
  test?: boolean;
73
77
  build?: () => unknown | Promise<unknown>;
@@ -10,7 +10,8 @@ Build-time only (Bun APIs). Never import this from browser code.
10
10
  import * as path from 'path';
11
11
  import { statSync, existsSync, readFileSync } from 'fs';
12
12
  import { tmpdir } from 'os';
13
- import { $, spawn } from 'bun';
13
+ import { $, spawn, gzipSync } from 'bun';
14
+ import { brotliCompressSync, constants as zlibConstants } from 'node:zlib';
14
15
  import { buildSite } from './orchestrator.js';
15
16
  import { preflight } from './preflight.js';
16
17
  import { auditDependencies, reportAudit } from './audit-guard.js';
@@ -66,6 +67,12 @@ then shipped an instance of. A range is not a pin, and a cached range is not eve
66
67
  is something an adopter controls with the tool they already use. `HALTIJA_VERSION` still
67
68
  overrides everything.
68
69
  */
70
+ /**
71
+ * Seconds `Bun.serve` will wait on a slow transfer before closing it. Bun's default is 10,
72
+ * sized for API responses rather than multi-MB bundles over LAN wifi (#63). Override with
73
+ * `DEV_REQUEST_TIMEOUT_SECONDS`; Bun caps this at 255.
74
+ */
75
+ const DEV_IDLE_TIMEOUT_SECONDS = Math.min(255, Number(process.env.DEV_REQUEST_TIMEOUT_SECONDS) || 120);
69
76
  const HALTIJA_PKG = process.env.HALTIJA_VERSION ?? 'haltija@^1.11.2';
70
77
  /*
71
78
  Which haltija do we actually run, and can we say so out loud?
@@ -335,6 +342,70 @@ async function ensureDevCerts() {
335
342
  'install instructions if it is missing.\n');
336
343
  process.exit(1);
337
344
  }
345
+ /*
346
+ Compress text-shaped assets on the way out.
347
+
348
+ The dev server serves REAL DEVICES over the LAN — that is what the mkcert cert covers
349
+ `<host>.local` for — and a doc site's bundle can be multiple MB. Everything text-shaped
350
+ here compresses to about 30%: this repo's 1.21MB iife goes to 0.39MB gzipped, 0.36MB
351
+ brotli; tosijs-3d's 10MB one goes to roughly 3MB. That is the real fix for the LAN stalls
352
+ behind #63 — raising `idleTimeout` stopped the connection dying, this stops it needing the
353
+ extra time.
354
+
355
+ Deliberately DEV-SERVER ONLY. Distribution is a host's job — Cloudflare, Firebase and
356
+ friends do this better and for free — so the build emits nothing precompressed.
357
+
358
+ Brotli at quality 5, not 11: measured on the 1.21MB iife, q5 takes 19ms for 0.36MB while
359
+ q11 takes **1169ms** for 0.32MB. Sixty times the cost for four percent.
360
+ */
361
+ const COMPRESSIBLE = /\.(js|mjs|css|html|json|svg|map|txt|xml|md|wasm)$/i;
362
+ /*
363
+ A BOUNDED cache, because this process lives for days.
364
+
365
+ Recompressing a 10MB bundle per request would cost more than it saves, so the bytes have
366
+ to be kept — but an unbounded cache in a long-lived dev server is precisely the failure
367
+ that took this machine down twice, and it is invisible to the JS heap until it is not.
368
+ Keyed on path + mtime so a rebuild invalidates naturally; capped in total bytes with
369
+ oldest-out eviction.
370
+ */
371
+ const COMPRESS_CACHE_MAX_BYTES = 64 * 1024 * 1024;
372
+ const compressCache = new Map();
373
+ let compressCacheBytes = 0;
374
+ function cacheCompressed(key, bytes) {
375
+ // A single asset bigger than the whole budget is not worth evicting everything for.
376
+ if (bytes.byteLength > COMPRESS_CACHE_MAX_BYTES / 2)
377
+ return;
378
+ while (compressCacheBytes + bytes.byteLength > COMPRESS_CACHE_MAX_BYTES &&
379
+ compressCache.size) {
380
+ const oldest = compressCache.keys().next().value;
381
+ compressCacheBytes -= compressCache.get(oldest).byteLength;
382
+ compressCache.delete(oldest);
383
+ }
384
+ compressCache.set(key, bytes);
385
+ compressCacheBytes += bytes.byteLength;
386
+ }
387
+ function compressBytes(bytes, encoding) {
388
+ return encoding === 'br'
389
+ ? new Uint8Array(brotliCompressSync(bytes, {
390
+ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5 },
391
+ }))
392
+ : gzipSync(bytes);
393
+ }
394
+ /** br if the client takes it, else gzip, else nothing. */
395
+ export function negotiateEncoding(accept) {
396
+ if (!accept)
397
+ return null;
398
+ const a = accept.toLowerCase();
399
+ if (a.includes('br'))
400
+ return 'br';
401
+ if (a.includes('gzip'))
402
+ return 'gzip';
403
+ return null;
404
+ }
405
+ /** Is this worth compressing? Already-compressed formats only get bigger. */
406
+ export function isCompressible(filePath) {
407
+ return COMPRESSIBLE.test(filePath);
408
+ }
338
409
  export async function devServer(config, opts = {}) {
339
410
  // PORT env wins over the config, so a test harness can bring up its own instance on
340
411
  // its own port instead of adopting (or killing — killStrayServer takes the port)
@@ -532,15 +603,42 @@ export async function devServer(config, opts = {}) {
532
603
  async function respondFile(filePath, request, viaTunnel = false) {
533
604
  const extras = (haltijaDev ? HALTIJA_SNIPPET : '') +
534
605
  (testMode ? '' : statusSnippet(request, viaTunnel));
606
+ const encoding = negotiateEncoding(request?.headers.get('accept-encoding') ?? null);
535
607
  if (extras && filePath.endsWith('.html')) {
536
608
  const html = await Bun.file(filePath).text();
537
609
  const injected = html.includes('</body>')
538
610
  ? html.replace('</body>', `${extras}</body>`)
539
611
  : html + extras;
540
- return new Response(injected, {
541
- headers: { 'Content-Type': 'text/html; charset=utf-8' },
612
+ // Injected HTML is built per request, so it is compressed but never cached — the
613
+ // cache key would have to include the injected extras, and HTML is small anyway.
614
+ const body = encoding
615
+ ? compressBytes(new TextEncoder().encode(injected), encoding)
616
+ : injected;
617
+ return new Response(body, {
618
+ headers: {
619
+ 'Content-Type': 'text/html; charset=utf-8',
620
+ ...(encoding ? { 'Content-Encoding': encoding, Vary: 'Accept-Encoding' } : {}),
621
+ },
622
+ });
623
+ }
624
+ if (encoding && isCompressible(filePath)) {
625
+ const file = Bun.file(filePath);
626
+ const key = `${encoding}:${filePath}:${file.lastModified}`;
627
+ let bytes = compressCache.get(key);
628
+ if (!bytes) {
629
+ bytes = compressBytes(new Uint8Array(await file.arrayBuffer()), encoding);
630
+ cacheCompressed(key, bytes);
631
+ }
632
+ return new Response(bytes, {
633
+ headers: {
634
+ 'Content-Type': file.type || 'application/octet-stream',
635
+ 'Content-Encoding': encoding,
636
+ Vary: 'Accept-Encoding',
637
+ },
542
638
  });
543
639
  }
640
+ // Everything else streams untouched — images, fonts, epubs and glb are already
641
+ // compressed, and re-encoding them only makes them bigger.
544
642
  return new Response(Bun.file(filePath));
545
643
  }
546
644
  async function handleTestReport(request) {
@@ -1144,8 +1242,22 @@ export async function devServer(config, opts = {}) {
1144
1242
  and the OS closes the sockets, and calling `.stop()` first is what segfaulted on the
1145
1243
  idle path (#47). A handle we would never legitimately use is an invitation to use it.
1146
1244
  */
1245
+ /*
1246
+ A generous idleTimeout, because this serves BUNDLES to REAL DEVICES.
1247
+
1248
+ `Bun.serve` defaults to 10s, which is sized for small API responses. A doc site's iife
1249
+ can be multiple MB — tosijs-3d ships ~10MB unminified on purpose, so its source stays
1250
+ browseable — and over LAN wifi to a phone or a second laptop that legitimately takes
1251
+ longer than 10s. Bun then closes the connection mid-transfer and the page half-loads.
1252
+
1253
+ It never reproduces on loopback, so it presents as a mysterious client-side stall; the
1254
+ reporter first suspected a bundle-size regression (tosijs-ui#63). Serving real devices
1255
+ over the LAN is exactly what the mkcert cert covers `<host>.local` FOR, so the default
1256
+ is wrong for this server specifically.
1257
+ */
1147
1258
  Bun.serve({
1148
1259
  port: PORT,
1260
+ idleTimeout: DEV_IDLE_TIMEOUT_SECONDS,
1149
1261
  tls: {
1150
1262
  key: Bun.file('./tls/key.pem'),
1151
1263
  cert: Bun.file('./tls/certificate.pem'),
@@ -1184,6 +1296,8 @@ export async function devServer(config, opts = {}) {
1184
1296
  try {
1185
1297
  Bun.serve({
1186
1298
  port: tunnelPort,
1299
+ // Same reasoning as the main listener — a tunnelled load is slower, not faster.
1300
+ idleTimeout: DEV_IDLE_TIMEOUT_SECONDS,
1187
1301
  hostname: '127.0.0.1',
1188
1302
  fetch: (request, srv) => handleRequest(request, srv, true),
1189
1303
  });
@@ -599,6 +599,25 @@ export async function buildSite(config, opts = {}) {
599
599
  '--minify',
600
600
  '--entry-naming',
601
601
  'hydrate.js',
602
+ /*
603
+ Code-split chunks go in a SUBDIRECTORY, not the web root.
604
+
605
+ `--splitting` emits one hashed chunk per dynamic import, and they landed flat
606
+ beside `index.html`. For a corpus that pulls something large — Babylon's glTF
607
+ extensions, shader chunks, audio engines — that is thousands of files in the root:
608
+ tosijs-3d reported **2,473 hashed chunks** among 2,604 files (tosijs-ui#64).
609
+
610
+ Three costs, and the second is the dangerous one. `ls docs` stops being usable.
611
+ Any dependency bump rewrites every hash, so a Babylon upgrade becomes a
612
+ multi-thousand-file commit — which makes the standing "don't commit docs/ from a
613
+ feature push" hazard far worse, because a stray `git add -A` now moves thousands
614
+ of files and the diff is unreviewable. (They took their doc site down exactly that
615
+ way.) And a genuinely stale or missing artifact is invisible among the lookalikes.
616
+ */
617
+ '--chunk-naming',
618
+ '_chunks/[name]-[hash].[ext]',
619
+ '--asset-naming',
620
+ '_assets/[name]-[hash].[ext]',
602
621
  ...externals.flatMap((ext) => ['--external', ext]),
603
622
  ], { stdout: 'inherit', stderr: 'inherit' });
604
623
  if ((await esm.exited) !== 0) {