@develemit/web-hooks 0.3.0 → 0.4.0

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 - 2026-11-06
4
+
5
+ - `terminal-output`: `useFollowTail` detaches on a user scroll up even when output streams every frame. It now detects intent (wheel up, a finger dragging down, a press on the scrollbar gutter, or a scroll event above where the hook last scrolled to) instead of ignoring scroll events in the frame after its own scroll. New pure `isUserScrollUp`.
6
+ - `terminal-output`: `useFollowTail({ scrollRef, itemCount, scrollToEnd, resetKey, threshold })` returning `{ atBottom, unread, jumpToBottom }`, plus the pure `shouldStick`, `isNearEnd`, `distanceFromEnd` and `unreadDelta`. Follows new output while at the bottom (24px threshold, so iOS rubber-band counts), detaches on a user scroll up without being fooled by its own scrolls, counts unread items, and works with a virtualizer through `scrollToEnd`.
7
+ - `terminal-output`: log helpers on plain lines. `filterLines` and `splitByMatch` (case-insensitive plain-text search and highlight), `findFailures` / `firstFailureIndex` (per-tool patterns for Vitest, Nx, tsc, pnpm, npm and Docker, returning `{ index, tool, kind }`), `isNoise` and `tailMeaningful`. `FAILURE_PATTERNS` and `NOISE_PATTERNS` are exported so apps can extend them.
8
+ - `terminal-output`: `createAnsiStream().finish(partial?)` ends a stream without adding a trailing newline (pushes after it are ignored until `reset()`). `push()` now re-renders only rows that were written to, instead of cloning and rendering the whole tail on every call.
9
+ - `terminal-output`: `createLineBuffer` (stream chunks or bytes to complete lines; holds the unfinished line and any half-written escape code, joins `\r\n` and UTF-8 split across chunks, caps memory with a `dropped` count, tracks the server's resume cursor) and `createAnsiStream` (renders those lines with a small screen tail, so streamed cursor-up redraws give the same rows as `parseAnsi` of the whole log).
10
+ - `terminal-output`: `stripAnsi`, `parseAnsi` and `createAnsiParser`. Styled segments with `\r`, cursor-up and erase-line redraws collapsed, OSC 8 links filtered to `http`/`https`/`file`, no HTML anywhere.
11
+ - `terminal-output`: new subpath. Holds the real terminal captures (`src/terminal-output/fixtures/`, test-only, not published) that the ANSI strip, parse, line-buffer and log helpers will be tested against.
12
+
13
+ ## 0.3.1 - 2026-11-06
14
+
15
+ - `service-worker`: `useServiceWorker({ onError })` and `registerServiceWorker({ onError })`. Called with the rejection when `register()` fails (not after unmount or dispose). The latest callback is used; a throwing callback is swallowed. Registration failure still never throws into React.
16
+ - `push`: an empty or whitespace `vapidPublicKey` is "not ready". The on-load re-subscribe waits for the key and runs once when it arrives; `subscribe()` sets `error` to `'push key not ready'` and never prompts or calls `pushManager.subscribe`. `shouldResubscribe` takes an optional `hasKey` (default `true`); new `isKeyReady`.
17
+ - `service-worker`: a worker that skips waiting no longer makes the tab reload unasked on `controllerchange`. The hook reports `updateAvailable` and `applyUpdate()` reloads once. **Behaviour change:** this also replaces the old behaviour of following another tab's update with an automatic reload; that tab now shows the update prompt instead. No app relied on the old behaviour, so it ships as a patch.
18
+
3
19
  ## 0.3.0 - 2026-11-06
4
20
 
5
21
  - `push`: `usePushSubscription({ vapidPublicKey, onSubscribe, onUnsubscribe, resubscribeOnLoad = true })` returning `{ support, permission, subscribed, busy, error, subscribe, unsubscribe }`, plus `urlBase64ToUint8Array`, `getPushSupport` (`'supported' | 'unsupported' | 'ios-needs-install'`) and the pure `shouldResubscribe`. The VAPID key is always converted to a `Uint8Array`, the browser's `pushManager.getSubscription()` is the source of truth, mounting never asks for permission, a dropped iOS subscription is re-made silently when permission is already granted, and an `onSubscribe` rejection with `status` 410 unsubscribes and subscribes once more. The package makes no HTTP requests; the app's callbacks do.
package/README.md CHANGED
@@ -24,6 +24,7 @@ that every app ends up hand-copying from the last one.
24
24
  | `@develemit/web-hooks/share` | Native share sheet with a clipboard fallback that works over plain http |
25
25
  | `@develemit/web-hooks/keyboard-inset` | On-screen keyboard height, without the phantom gap in standalone iPhone PWAs |
26
26
  | `@develemit/web-hooks/push` | Web push subscription, with iOS install detection and silent re-subscribe |
27
+ | `@develemit/web-hooks/terminal-output` | Strip and parse ANSI, buffer streamed lines, search logs and find failures, and follow the tail of a scrolling log |
27
28
 
28
29
  Behavior only. No styling and no components: each app keeps its own look.
29
30
 
@@ -301,6 +302,7 @@ const { updateAvailable, applyUpdate, supported } = useServiceWorker({
301
302
  url: '/sw.js',
302
303
  scope: '/',
303
304
  enabled: process.env.NODE_ENV === 'production',
305
+ onError: (error) => captureError(error), // registration failed; never throws into React
304
306
  });
305
307
  if (updateAvailable) showBanner({ onTap: applyUpdate });
306
308
  ```
@@ -315,6 +317,12 @@ self.addEventListener('message', (event) => {
315
317
  });
316
318
  ```
317
319
 
320
+ Workers that call `self.skipWaiting()` on install (as emit-infra's does) take
321
+ control with no waiting stage. The page is never reloaded unasked: when a new
322
+ worker takes control of a tab that was already controlled, `updateAvailable`
323
+ becomes `true` and `applyUpdate()` reloads the page once. The same applies when
324
+ another tab applied the update.
325
+
318
326
  A first install (no controller yet) is not reported as an update. In dev, call
319
327
  `unregisterAll()` to clear workers left by a production build. Browsers without
320
328
  service workers get `supported: false`.
@@ -373,6 +381,11 @@ if (push.support === 'ios-needs-install') return <InstallHint />;
373
381
  <button onClick={push.subscribe} disabled={push.busy}>Enable</button>
374
382
  ```
375
383
 
384
+ `vapidPublicKey` may be `''` while the app is still fetching it. Until it is
385
+ non-blank, the on-load re-subscribe is skipped (and runs once when the key
386
+ arrives) and `subscribe()` sets `error` to `'push key not ready'` without
387
+ asking for permission or touching the push manager.
388
+
376
389
  Returns `{ support, permission, subscribed, busy, error, subscribe, unsubscribe }`.
377
390
  `support` is `'supported'`, `'unsupported'` or `'ios-needs-install'`: iOS only
378
391
  allows Web Push in an installed PWA (16.4+), so an iPhone Safari tab (or any
@@ -393,3 +406,54 @@ the dead subscription is unsubscribed and one fresh one is made and sent. The
393
406
  package never makes HTTP requests: `onSubscribe(subscription:
394
407
  PushSubscriptionJSON)` and `onUnsubscribe(endpoint)` are yours. The pure
395
408
  `urlBase64ToUint8Array`, `getPushSupport` and `shouldResubscribe` are exported.
409
+
410
+ ### terminal-output
411
+
412
+ `@develemit/web-hooks/terminal-output` turns raw terminal text into data. Log helpers (search, failures, noise) work on plain lines. Tests run against real captures in `src/terminal-output/fixtures/` (not published).
413
+
414
+ - `stripAnsi(text)` removes CSI (including `?` private modes), OSC (BEL or `ESC \` terminated) and two-byte escapes. It leaves `\r` and `\n` alone.
415
+ - `parseAnsi(text, { linkSchemes? })` returns `AnsiLine[]`, each line an array of `{ text, fg?, bg?, bold?, dim?, italic?, underline?, inverse?, href? }`. Colours are `{ kind: 'ansi', index }` (0-15, or 0-255 for 256-colour) or `{ kind: 'rgb', r, g, b }`; your app maps them to its palette. OSC 8 links become `href`, and only `http`, `https` and `file` are kept.
416
+ - It emulates what progress output needs: `\r`, cursor up/down (`A`/`B`), column set (`G`), erase-line (`K`) and erase-below (`0J`), so redraws collapse to their final state. Every other escape sequence is dropped; use xterm.js for full emulation.
417
+ - `createAnsiParser(options)` returns `{ parse(chunk), reset() }`. Colour and link state carry across calls; the screen does not, so give it whole lines.
418
+
419
+ - `createLineBuffer({ maxLines })` turns chunks (`string` or `Uint8Array`) into complete raw lines. `push(chunk, { cursor? })` returns `{ lines, partial, dropped }`: the new lines (escape codes intact), the unfinished line (without a half-written escape code, which waits for the next chunk), and how many old lines this push dropped. `\r\n` and multi-byte characters split across chunks are joined; a lone `\r` stays in the line for the parser. `flush()` ends the stream, `buffer.lines` holds what is kept, `buffer.dropped` the total dropped.
420
+ - **Resuming is the server's job.** Pass its cursor to `push`; after a dropped connection ask the server for `since: buffer.cursor` and push again. The buffer never compares text, so a log that repeats a line keeps both.
421
+ - `createAnsiStream({ tailLines = 50 })` renders those lines. `push(lines, partial)` returns `{ replaced?: { from, lines } }`: truncate your rendered rows to `from`, then append `lines`. Its screen tail lets a redraw reach rows already shown (pnpm's cursor-up progress, npm's spinner), so the result equals `parseAnsi(wholeLog)`. The tail grows to the largest cursor-up it sees; rows further back are frozen. `from` counts from the stream's first row, not from your capped list. When the stream ends, call `stream.finish()`: it renders the unfinished line you last pushed (or the `partial` you pass) without adding a newline, so the rows equal `parseAnsi(wholeLog)`. Don't push `lineBuffer.flush()` output into the stream. Pushes after `finish()` are ignored until `reset()`.
422
+
423
+ ```ts
424
+ const buffer = createLineBuffer({ maxLines: 10_000 });
425
+ const stream = createAnsiStream();
426
+ const rows: AnsiLine[] = [];
427
+
428
+ function onChunk(chunk: string | Uint8Array, cursor: number) {
429
+ const { lines, partial } = buffer.push(chunk, { cursor });
430
+ const { replaced } = stream.push(lines, partial);
431
+ if (replaced) rows.splice(replaced.from, rows.length, ...replaced.lines);
432
+ }
433
+ ```
434
+
435
+ **Log helpers** take plain lines (run `stripAnsi` or join `parseAnsi` segments first):
436
+
437
+ - `filterLines(lines, query)` returns the matching indexes (every index for a blank query). `splitByMatch(line, query)` returns `{ text, match }[]` for highlighting. Both are case-insensitive plain text, never regex.
438
+ - `findFailures(lines, patterns?)` returns `{ index, tool, kind }[]` (tools: `vitest`, `nx`, `tsc`, `pnpm`, `npm`, `docker`, `generic`); `firstFailureIndex` returns the first index or `-1`. Nx matches both `targets failed, including` and the older `Failed tasks`. Stack-frame lines never count. Extend with `[...FAILURE_PATTERNS, { tool, kind, pattern }]`.
439
+ - `isNoise(line)` and `tailMeaningful(lines, n)` hide Docker pull and compose chatter and Nx cache lines; the tail is the last `n` non-blank lines that matter. Extend `NOISE_PATTERNS` the same way.
440
+
441
+ **Following live output.** `useFollowTail({ scrollRef, itemCount, scrollToEnd?, resetKey?, threshold = 24 })` returns `{ atBottom, unread, jumpToBottom }`. While the user is within `threshold` px of the end (rubber-band overshoot counts), new items scroll to the end in a layout effect. A user scroll up detaches; items added after that are counted in `unread`; scrolling back down or calling `jumpToBottom()` re-sticks and clears it. The hook's own scrolls never detach. Pass `scrollToEnd` to use a virtualizer (`() => virtualizer.scrollToIndex(count - 1, { align: 'end' })`), and `resetKey` to snap back when the content changes (a project switch). The pure `shouldStick`, `isNearEnd`, `distanceFromEnd` and `unreadDelta` are exported.
442
+
443
+ Segments are plain text, so there is no HTML to escape:
444
+
445
+ ```tsx
446
+ import { parseAnsi } from '@develemit/web-hooks/terminal-output';
447
+
448
+ const COLORS = ['text-black', 'text-red', 'text-green', 'text-yellow', 'text-blue', 'text-magenta', 'text-cyan', 'text-white'];
449
+
450
+ {parseAnsi(log).map((line, i) => (
451
+ <div key={i}>
452
+ {line.map((seg, j) => (
453
+ <span key={j} className={`${seg.bold ? 'font-bold' : ''} ${seg.fg?.kind === 'ansi' ? COLORS[seg.fg.index % 8] : ''}`}>
454
+ {seg.text}
455
+ </span>
456
+ ))}
457
+ </div>
458
+ ))}
459
+ ```
@@ -27,8 +27,11 @@ function readPermission() {
27
27
  if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
28
28
  return Notification.permission;
29
29
  }
30
- function shouldResubscribe({ permission, hasSubscription, resubscribeOnLoad }) {
31
- return resubscribeOnLoad && permission === "granted" && !hasSubscription;
30
+ function isKeyReady(key) {
31
+ return key.trim().length > 0;
32
+ }
33
+ function shouldResubscribe({ permission, hasSubscription, resubscribeOnLoad, hasKey = true }) {
34
+ return hasKey && resubscribeOnLoad && permission === "granted" && !hasSubscription;
32
35
  }
33
36
  function isGone(error) {
34
37
  return typeof error === "object" && error !== null && error.status === 410;
@@ -74,6 +77,7 @@ function usePushSubscription(options) {
74
77
  const [subscribed, setSubscribed] = useState(false);
75
78
  const [busy, setBusy] = useState(false);
76
79
  const [error, setError] = useState(null);
80
+ const hasKey = isKeyReady(options.vapidPublicKey);
77
81
  const latest = useRef(options);
78
82
  latest.current = options;
79
83
  useEffect(() => {
@@ -90,7 +94,7 @@ function usePushSubscription(options) {
90
94
  const permissionNow = readPermission();
91
95
  setPermission(permissionNow);
92
96
  if (existing) return setSubscribed(true);
93
- if (!shouldResubscribe({ permission: permissionNow, hasSubscription: false, resubscribeOnLoad })) return;
97
+ if (!shouldResubscribe({ permission: permissionNow, hasSubscription: false, resubscribeOnLoad, hasKey })) return;
94
98
  await ensureSubscribed(reg, latest.current);
95
99
  if (!cancelled) setSubscribed(true);
96
100
  } catch {
@@ -99,9 +103,13 @@ function usePushSubscription(options) {
99
103
  return () => {
100
104
  cancelled = true;
101
105
  };
102
- }, [resubscribeOnLoad]);
106
+ }, [resubscribeOnLoad, hasKey]);
103
107
  const subscribe = useCallback(async () => {
104
108
  if (getPushSupport() !== "supported") return;
109
+ if (!isKeyReady(latest.current.vapidPublicKey)) {
110
+ setError("push key not ready");
111
+ return;
112
+ }
105
113
  setError(null);
106
114
  setBusy(true);
107
115
  try {