ranuts 0.4.1-alpha.1 → 0.4.1-alpha.2

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.
Files changed (38) hide show
  1. package/CLAUDE.md +156 -11
  2. package/README.md +2 -0
  3. package/README.zh-CN.md +2 -0
  4. package/dist/{color-B_KMM8UX.js → color-BTvZUuf-.js} +1 -1
  5. package/dist/i18n-DW21b_DJ.js +1 -0
  6. package/dist/index.js +1 -1
  7. package/dist/src/conversation/engine.d.ts +109 -0
  8. package/dist/src/conversation/index.d.ts +12 -0
  9. package/dist/src/conversation/index.js +1 -0
  10. package/dist/src/conversation/types.d.ts +99 -0
  11. package/dist/src/node/index.js +1 -1
  12. package/dist/src/stream/accumulator.d.ts +51 -0
  13. package/dist/src/stream/budget.d.ts +89 -0
  14. package/dist/src/stream/index.d.ts +25 -0
  15. package/dist/src/stream/index.js +1 -0
  16. package/dist/src/stream/sse.d.ts +51 -0
  17. package/dist/src/stream/types.d.ts +106 -0
  18. package/dist/src/utils/canvas.d.ts +1 -0
  19. package/dist/src/utils/diff.d.ts +51 -0
  20. package/dist/src/utils/i18n/index.js +1 -1
  21. package/dist/src/utils/index.d.ts +9 -5
  22. package/dist/src/utils/index.js +1 -1
  23. package/dist/src/utils/number.d.ts +20 -0
  24. package/dist/src/utils/placement.d.ts +42 -5
  25. package/dist/src/utils/scroll.d.ts +93 -0
  26. package/dist/test/budget.test.d.ts +1 -0
  27. package/dist/test/conversation.test.d.ts +1 -0
  28. package/dist/test/diff.test.d.ts +1 -0
  29. package/dist/test/formatBytes.test.d.ts +1 -0
  30. package/dist/test/scroll.test.d.ts +1 -0
  31. package/dist/test/stream.test.d.ts +1 -0
  32. package/dist/umd/index.umd.cjs +1 -1
  33. package/dist/umd/utils/utils.umd.cjs +1 -1
  34. package/dist/utils-B4FnyUzW.js +1 -0
  35. package/docs/API.md +80 -3
  36. package/package.json +11 -2
  37. package/dist/i18n-BJIS1fR1.js +0 -1
  38. package/dist/utils-BNwhohXq.js +0 -1
package/CLAUDE.md CHANGED
@@ -13,7 +13,8 @@ and Node server tooling. Multi-entry, tree-shakeable, ships ESM + UMD.
13
13
 
14
14
  - **[docs/API.md](docs/API.md)** — ⭐ generated, authoritative list of **every exported
15
15
  symbol** with its signature + one-line description, grouped by entry point. Regenerate
16
- with `npm run doc:api` after changing any public API. Read this to find what exists.
16
+ with `npm run doc:api` after changing any public API — CI fails if you forget. Read
17
+ this to find what exists.
17
18
  - **This file** — orientation: which entry to import, runtime constraints, conventions,
18
19
  and gotchas. Read this to decide _where_ things go and _how_ to add them.
19
20
 
@@ -27,15 +28,17 @@ from the **subpath that owns it** (below) — not from a deep source path.
27
28
  Each subpath is an independent, tree-shakeable barrel. Import from the subpath, never from
28
29
  `ranuts/dist/...` or `@/...` (that alias is internal to the source).
29
30
 
30
- | Import from | Source | What | Runtime |
31
- | --------------- | --------------------------- | --------------------------------------------------- | ------------------ |
32
- | `ranuts` | `index.ts` | Root barrel — re-exports the utils + visual surface | browser + node |
33
- | `ranuts/utils` | `src/utils/index.ts` | DOM/BOM, string, object, number, color, time, etc. | browser + node\* |
34
- | `ranuts/node` | `src/node/index.ts` | HTTP server, router, ws, fs, streams, middleware | **node only** |
35
- | `ranuts/visual` | `src/utils/visual/index.ts` | 2D rendering engine (Canvas / WebGL / WebGPU) | **browser only** |
36
- | `ranuts/i18n` | `src/utils/i18n.ts` | `I18nCore` / `createI18n` / `useI18n` — DOM-free | browser + node |
37
- | `ranuts/sw` | `src/sw/index.ts` | Cache strategies + the precache protocol's SW half | **service worker** |
38
- | `ranuts/vnode` | `src/vnode/index.ts` | Snabbdom-style virtual DOM (`h`, `init`, modules) | browser |
31
+ | Import from | Source | What | Runtime |
32
+ | --------------------- | --------------------------- | --------------------------------------------------------------- | ------------------ |
33
+ | `ranuts` | `index.ts` | Root barrel — re-exports the utils + visual surface | browser + node |
34
+ | `ranuts/utils` | `src/utils/index.ts` | DOM/BOM, string, object, number, color, time, etc. | browser + node\* |
35
+ | `ranuts/node` | `src/node/index.ts` | HTTP server, router, ws, fs, streams, middleware | **node only** |
36
+ | `ranuts/visual` | `src/utils/visual/index.ts` | 2D rendering engine (Canvas / WebGL / WebGPU) | **browser only** |
37
+ | `ranuts/i18n` | `src/utils/i18n.ts` | `I18nCore` / `createI18n` / `useI18n` — DOM-free | browser + node |
38
+ | `ranuts/sw` | `src/sw/index.ts` | Cache strategies + the precache protocol's SW half | **service worker** |
39
+ | `ranuts/vnode` | `src/vnode/index.ts` | Snabbdom-style virtual DOM (`h`, `init`, modules) | browser |
40
+ | `ranuts/stream` | `src/stream/index.ts` | SSE parsing + provider-neutral model-stream fold + token budget | browser + node |
41
+ | `ranuts/conversation` | `src/conversation/index.ts` | Event log → renderable conversation nodes | browser + node |
39
42
 
40
43
  \* `ranuts/utils` is broad: most functions are browser-oriented (touch `window`/`document`),
41
44
  but pure helpers (`str`, `obj`, `number`, `compose`, `cloneDeep`, …) run anywhere. Functions
@@ -73,10 +76,14 @@ packages/ranuts/
73
76
  │ │ ├── canvas.ts # Canvas 2D geometry — roundRectByArc, fanShapedByArc, getLinearGradient
74
77
  │ │ ├── placement.ts # computePlacement — flip/shift floating-panel placement (re-exported by ranui/utils/placement)
75
78
  │ │ ├── tween.ts # easing curves (quad/cubic/quart/quint/sine/expo/circ)
79
+ │ │ ├── scroll.ts # createBottomFollower — follow the floor without fighting the reader
80
+ │ │ ├── diff.ts # diffLines — line-level diff as unified-style hunks
76
81
  │ │ ├── visual/ # ranuts/visual — 2D rendering engine (see below)
77
82
  │ │ └── totp/ # TOTP + hand-rolled SHA
78
83
  │ ├── node/ # ranuts/node — mini HTTP framework
79
- │ └── vnode/ # ranuts/vnode — virtual DOM
84
+ │ ├── vnode/ # ranuts/vnode — virtual DOM
85
+ │ ├── stream/ # ranuts/stream — SSE + StreamChunk + accumulator + budget
86
+ │ └── conversation/ # ranuts/conversation — event log → nodes, with publication cadence
80
87
  ├── bin/
81
88
  │ ├── build.sh # build (tsc types + vite es/umd)
82
89
  │ └── generate-api-docs.ts # ⭐ doc:api — emits docs/API.md from source + JSDoc
@@ -126,6 +133,137 @@ in `utils/number.ts`; `srgbToLinear`/`luma`/`blend*`/`saturation`/`cosinePalette
126
133
 
127
134
  ---
128
135
 
136
+ ## Streaming a model response
137
+
138
+ `ranuts/stream` is four layers, each usable alone. The split is the point: only the
139
+ middle one is vendor-specific, and it is the one ranuts does **not** ship.
140
+
141
+ 1. `parseEventStream(source)` — bytes → `ServerSentEvent`. Transport only, no model
142
+ concepts. Handles the parts that bite: a chunk boundary anywhere (including inside a
143
+ multi-byte character or between `\r` and `\n`), repeated `data:` joined with `\n`, one
144
+ space stripped after the colon, `:` comment keep-alives, a leading BOM, and a trailing
145
+ block the server never terminated.
146
+ 2. `StreamChunk` — the provider-neutral vocabulary. **You write the mapping** from your
147
+ provider's event shape onto it; `mapEventStream(source, map)` is the seam, and returning
148
+ `[]` from the mapping is how a keep-alive or a `[DONE]` sentinel is dropped.
149
+ 3. `createStreamAccumulator()` — folds chunks into blocks, so a view reads `snapshot()`,
150
+ `text()`, `reasoning()`, or `toolCalls()` instead of concatenating deltas itself.
151
+ 4. `estimateTokens` / `addUsage` / `planCompaction` — the budget. A chat client that skips
152
+ this works for a week and then stops: every turn carries the whole history, the request
153
+ grows monotonically, and one day the provider refuses it as a wall.
154
+
155
+ Non-obvious rules the vocabulary encodes:
156
+
157
+ - **Group by `index`, never by arrival order.** Reasoning and text interleave, and several
158
+ tool calls open at once.
159
+ - **Tool arguments stay raw JSON text.** Half a JSON document is not a value. The
160
+ accumulator never parses `argumentsDelta`; parse once, after `finish`.
161
+ - **`block-start` is optional.** Several providers open a block with its first delta, so
162
+ the accumulator opens one on demand. Do not require it in your mapping either.
163
+ - **`block-end` wins.** When a provider sends an assembled block, it replaces whatever the
164
+ deltas accumulated.
165
+ - **`finish` terminates.** `usage` arrives before it; nothing after it.
166
+
167
+ Non-obvious rules the budget encodes:
168
+
169
+ - **`estimateTokens` is an estimate, and CJK is not four characters a token.** A BPE
170
+ vocabulary trained mostly on Latin script rarely merges CJK, so counting it at the Latin
171
+ rate underestimates a Chinese conversation about fourfold — the difference between
172
+ compacting in time and compacting after the provider refuses. A real tokenizer is a
173
+ per-model dependency measured in megabytes, and this decides _when to act_, not billing.
174
+ - **`addUsage` keeps a field one side omitted.** Providers differ in what they send, and a
175
+ running total that lost `outputTokens` the moment one response omitted it is worse than
176
+ no total.
177
+ - **`planCompaction` counts the summary it will add.** Without that it returns "fits" for a
178
+ request that does not. It also never touches the protected tail, and reports `fits: false`
179
+ rather than compacting it anyway — the caller decides, because nothing can be done there
180
+ that does not lose the user's own words.
181
+ - **It takes sizes, not messages.** What replaces a folded prefix, and which cut points a
182
+ wire format allows, are the caller's business.
183
+
184
+ ## Projecting an event log into a conversation
185
+
186
+ `ranuts/conversation` turns an append-only log into the nodes a view renders. The
187
+ alternative — a view that switches on event type and mutates a component tree — puts
188
+ ordering, identity and partial-update reconciliation in the view, where every new kind of
189
+ content has to be threaded through by hand.
190
+
191
+ Here each kind of content is an independently registered state machine:
192
+
193
+ ```ts
194
+ const engine = createConversationEngine<Event>({ definitions: [message, toolCall] });
195
+ engine.subscribe((nodes) => render(nodes));
196
+ engine.push(event);
197
+ ```
198
+
199
+ A definition says which events are its own (`match`), folds them into its own state
200
+ (`start` / `update`), and never learns that the others exist. Adding a kind is adding a
201
+ definition, not editing a renderer.
202
+
203
+ Semantics you need before writing one:
204
+
205
+ - **Every definition sees every event.** Claims are independent; the engine does not stop
206
+ at the first match, so one log event can drive two nodes.
207
+ - **Order is fixed at `start`.** A node that keeps updating stays where it opened, so a
208
+ streaming message does not jump to the end of the list on every delta.
209
+ - **An `update` for an id with no open node is dropped.** That is honest when the start
210
+ event was trimmed from a paged window; inventing a node from a partial update would
211
+ render something that never existed.
212
+ - **`publication` is the streaming throttle.** `animation-frame` coalesces every delta
213
+ between two paints into one notification; `immediate` is for discrete facts, where
214
+ waiting a frame only adds latency; `none` records without waking the view. Cadence
215
+ escalates and never relaxes — an `immediate` while a frame is pending fires now and
216
+ cancels the frame.
217
+ - **`reader.previous(kind)` is backward-only**, so replaying the same log reproduces the
218
+ same view. A definition that could see nodes started after it would not.
219
+ - **`batch(run)` for any replay.** Without it, restoring a stored conversation is
220
+ quadratic: every event publishes, every publication hands subscribers the whole node
221
+ list, and a DOM subscriber walks all of it. Measured on a 600-message transcript that was
222
+ 5.4 seconds of blocked main thread; batched it is 158ms. A live stream does not need it —
223
+ one delta changes one row.
224
+ - **A subscriber gets the keys that changed.** On a long transcript nearly every row is
225
+ unchanged on every frame, and rewriting all of them once per delta is the difference
226
+ between a transcript that streams and one that stalls. `undefined` means every node
227
+ changed, which is what a reset reports.
228
+ - **`truncate(key)` is what editing, regenerating and branching are made of.** All three
229
+ mean "the conversation diverges here", and what follows the divergence is no longer part
230
+ of it. It cuts by `seq` rather than by position — a node that opened before the cut
231
+ survives even if its latest update came after it, which is the same rule node order
232
+ follows — and returns how many nodes went, because zero is the caller's cue that its own
233
+ idea of the conversation is stale. Without it the only way back is `reset()` and a full
234
+ replay, which discards every node that did not change and makes the view flash.
235
+
236
+ `scheduler` is injectable, which is how the cadence tests run without a paint. The default
237
+ uses `requestAnimationFrame` in a browser and a microtask elsewhere.
238
+
239
+ `<r-conversation>` in ranui is the DOM consumer of all of this — see
240
+ [ranui/CLAUDE.md](../ranui/CLAUDE.md#r-conversation).
241
+
242
+ ## Following the bottom of a scroller
243
+
244
+ `createBottomFollower` (from `ranuts/utils`) keeps an append-only view — a streaming
245
+ transcript, a log tail, a terminal — pinned to its floor without fighting the reader.
246
+
247
+ The hard part is telling your own scroll writes apart from the reader's. It uses an
248
+ **observed-top ledger**: every programmatic write records the `scrollTop` it produced, and
249
+ a scroll event whose position deviates from the ledger is the reader. That covers wheel,
250
+ touch, scrollbar, keyboard and stray `scrollIntoView` calls at once, without listening for
251
+ any input device — no list of device listeners is ever complete.
252
+
253
+ Using it:
254
+
255
+ - Call `follow()` whenever content changes; it is a no-op while the reader is scrolled up.
256
+ - Pass `observe: [column]` for growth that appends no node — streaming text grows an
257
+ existing node, so it has to be observed rather than announced.
258
+ - Before loading older content, `captureAnchor(row)` on a row that is actually on screen,
259
+ and `restoreAnchor()` after the prepend lands. Resolving to `null` means the row did not
260
+ survive; omitting the resolver reuses the captured element.
261
+ - A shrink-clamp never transfers ownership. A reader who had scrolled up stays unpinned
262
+ even when the clamp leaves them at the floor, and re-pins on their next scroll —
263
+ re-pinning on a clamp would take control back with no input from them.
264
+ - Scrolling is always instant. Smooth scrolling animates toward a moving target during
265
+ streaming, and its intermediate positions read as reader input on the next event.
266
+
129
267
  ## Conventions
130
268
 
131
269
  ### Adding a function to an existing module
@@ -214,6 +352,7 @@ npm run tsc # type-check only (tsc --noEmit)
214
352
  npm run test # vitest run (node env)
215
353
  npm run test:coverage
216
354
  npm run doc:api # regenerate docs/API.md from source + JSDoc
355
+ npm run doc:api:check # verify it is fresh without writing (what CI runs)
217
356
  ```
218
357
 
219
358
  - **Tests**: Vitest, **node environment** (no jsdom) — `vitest.config.ts` has no `environment`
@@ -222,6 +361,11 @@ npm run doc:api # regenerate docs/API.md from source + JSDoc
222
361
  `getRgb`'s CSS fallback returns black without `document`).
223
362
  - **`onConsoleLog` throws**: the test config fails any test that logs to console — don't leave
224
363
  stray `console.*` in code under test.
364
+ - **Never assert on unseeded randomness**: sampling the real CSPRNG can only estimate the property
365
+ under test, and a window tight enough to catch a real bias also fails on chance every few
366
+ thousand runs — which reads as a mystery CI failure on one matrix leg. Replace the sample with
367
+ scripted bytes and assert the mapping exactly; `test/utils/secure.test.ts` has the `withBytes`
368
+ helper that stubs `crypto.getRandomValues` for the length of one draw.
225
369
 
226
370
  ---
227
371
 
@@ -241,6 +385,7 @@ npm run doc:api # regenerate docs/API.md from source + JSDoc
241
385
  | Branching on `isClient` in new code | It's a module-load-time constant — wrong after SSR, unstubbable in tests. Check `typeof window === 'undefined'` inside the function. |
242
386
  | Reaching for `randomString` / `getRandomString` to make a token or code | Both are `Math.random()` based and predictable. Use `secureRandomString` / `secureToken` from `secure.ts` for anything an attacker would want to guess. |
243
387
  | Comparing a secret with `===` | Returns early at the first differing byte, leaking the shared prefix through timing. Use `safeEqual`. |
388
+ | Asserting a distribution drawn from the real CSPRNG | The window that catches the bias also fires on sampling noise. Script the bytes with `withBytes` (`test/utils/secure.test.ts`) and assert an equality. |
244
389
  | Decoding chunked text per chunk | A multi-byte character straddling a boundary becomes replacement characters. `concatBytes` first, decode once. |
245
390
  | `window.setTimeout` in a `ranuts/utils` module | Throws outside a document. Use the bare `setTimeout`. |
246
391
  | A module-level factory that keeps mutable state shared by everything it produces | Give each produced function its own state (this is why `generateThrottle` was removed). |
package/README.md CHANGED
@@ -61,6 +61,8 @@ Import as required. You can select:
61
61
  - `ranuts/visual` — 2D rendering engine (Canvas / WebGL / WebGPU, **browser only**)
62
62
  - `ranuts/sw` — cache strategies + the precache protocol's service-worker half (**service worker only**)
63
63
  - `ranuts/vnode` — Snabbdom-style virtual DOM
64
+ - `ranuts/stream` — Server-Sent Events parsing, a provider-neutral fold of a streamed model response, and the token budget that decides when a history stops fitting
65
+ - `ranuts/conversation` — project an append-only event log into renderable conversation nodes
64
66
  - `ranuts/i18n` — the i18n engine on its own, without the rest of `utils`
65
67
 
66
68
  ```js
package/README.zh-CN.md CHANGED
@@ -60,6 +60,8 @@ npm install ranuts@latest --save
60
60
  - `ranuts/visual` —— 2D 渲染引擎(Canvas / WebGL / WebGPU,**仅浏览器**)
61
61
  - `ranuts/sw` —— 缓存策略 + 预缓存协议的 Service Worker 一侧(**仅 Service Worker**)
62
62
  - `ranuts/vnode` —— Snabbdom 风格的虚拟 DOM
63
+ - `ranuts/stream` —— Server-Sent Events 解析、对流式模型响应的厂商中立折叠,以及判断历史何时装不下的 token 预算
64
+ - `ranuts/conversation` —— 将只追加的事件日志投影为可渲染的对话节点
63
65
  - `ranuts/i18n` —— 单独的 i18n 引擎,不牵入 `utils` 的其余部分
64
66
 
65
67
  ```js