stitchkit 0.114.0 → 0.114.1
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 +29 -0
- package/README.md +1 -1
- package/dist/browser/client-url.d.ts.map +1 -1
- package/dist/entrypoints/agent-runtime/coding-tools.js +1 -1
- package/dist/entrypoints/agent-runtime/harness.js +4 -4
- package/dist/entrypoints/agent-runtime/realtime.js +2 -2
- package/dist/entrypoints/agent-runtime/sandbox.js +3 -3
- package/dist/entrypoints/agent-runtime/sqlite/bun.js +3 -3
- package/dist/entrypoints/agent-runtime/sqlite/node.js +3 -3
- package/dist/entrypoints/agent-runtime/testing.js +4 -4
- package/dist/entrypoints/agent-runtime.js +12 -12
- package/dist/entrypoints/application/directory-inbox.js +3 -3
- package/dist/entrypoints/application.js +4 -4
- package/dist/entrypoints/cli/publish.js +1 -1
- package/dist/entrypoints/cli.js +4 -4
- package/dist/entrypoints/contract.js +2 -2
- package/dist/entrypoints/http.js +2 -2
- package/dist/entrypoints/index.js +5 -5
- package/dist/entrypoints/node.js +3 -3
- package/dist/entrypoints/observability.js +3 -3
- package/dist/entrypoints/react/keyboard/testing.d.ts +2 -0
- package/dist/entrypoints/react/keyboard/testing.d.ts.map +1 -0
- package/dist/entrypoints/react/keyboard/testing.js +127 -0
- package/dist/entrypoints/react/keyboard.d.ts +4 -2
- package/dist/entrypoints/react/keyboard.d.ts.map +1 -1
- package/dist/entrypoints/react/keyboard.js +83 -319
- package/dist/entrypoints/react.js +15 -3
- package/dist/entrypoints/realtime.js +1 -1
- package/dist/entrypoints/remote.js +1 -1
- package/dist/entrypoints/server.js +18 -18
- package/dist/entrypoints/telegram/grammy.d.ts +2 -0
- package/dist/entrypoints/telegram/grammy.d.ts.map +1 -0
- package/dist/entrypoints/telegram/grammy.js +50 -0
- package/dist/entrypoints/telegram/screens.js +1 -1
- package/dist/entrypoints/telegram.js +16 -10
- package/dist/entrypoints/testing.js +3 -3
- package/dist/entrypoints/tools/connections.js +10 -10
- package/dist/entrypoints/tools.js +19 -19
- package/dist/entrypoints/tracking.js +1 -1
- package/dist/{index-hs35ss3p.js → index-61hve0ez.js} +4 -25
- package/dist/index-6j725ez5.js +25 -0
- package/dist/{index-1csp1hwm.js → index-6p3frkv5.js} +1 -1
- package/dist/{index-fy4av0hq.js → index-9bwcthxw.js} +1 -1
- package/dist/{index-523tq7y4.js → index-a3462dqf.js} +1 -1
- package/dist/{index-506kd458.js → index-aw6zahv1.js} +1 -1
- package/dist/{index-tpwbct8j.js → index-dc9ere5p.js} +1 -1
- package/dist/{index-erv5shjq.js → index-kdnfe0sr.js} +1 -1
- package/dist/{index-k7z3j3hr.js → index-km2qw407.js} +4 -4
- package/dist/{index-7fc0c2np.js → index-n3v695sb.js} +1 -1
- package/dist/index-p02zgk8x.js +351 -0
- package/dist/{index-vtann8bz.js → index-p0rf7sq9.js} +8 -8
- package/dist/{index-aykhzcnf.js → index-qj1sy4ep.js} +1 -1
- package/dist/{index-cxpcdswt.js → index-t65erk4z.js} +1 -1
- package/dist/{index-c5at3ykd.js → index-tsngj2b5.js} +22 -10
- package/dist/react/entity-cache.d.ts +24 -2
- package/dist/react/entity-cache.d.ts.map +1 -1
- package/dist/react/keyboard/hooks.d.ts +41 -10
- package/dist/react/keyboard/hooks.d.ts.map +1 -1
- package/dist/react/keyboard/layers.d.ts +27 -8
- package/dist/react/keyboard/layers.d.ts.map +1 -1
- package/dist/react/keyboard/modality.d.ts +21 -0
- package/dist/react/keyboard/modality.d.ts.map +1 -0
- package/dist/react/keyboard/refs.d.ts +13 -0
- package/dist/react/keyboard/refs.d.ts.map +1 -0
- package/dist/react/keyboard/testing.d.ts +80 -0
- package/dist/react/keyboard/testing.d.ts.map +1 -0
- package/dist/react/keyboard/zone.d.ts +60 -10
- package/dist/react/keyboard/zone.d.ts.map +1 -1
- package/dist/telegram/budget-transformer.d.ts +35 -0
- package/dist/telegram/budget-transformer.d.ts.map +1 -0
- package/dist/telegram/outbound-budget.d.ts +4 -2
- package/dist/telegram/outbound-budget.d.ts.map +1 -1
- package/llms/react-keyboard-testing.txt +24 -0
- package/llms/react-keyboard.txt +264 -4
- package/llms/react.txt +1 -1
- package/llms/server.3.txt +31 -0
- package/llms/stitchkit.2.txt +118 -0
- package/llms/stitchkit.txt +4 -118
- package/llms/telegram-grammy.txt +22 -0
- package/llms/telegram.txt +19 -7
- package/llms-full.txt +345 -12
- package/llms.txt +7 -5
- package/package.json +9 -1
- package/dist/{index-616xh7f2.js → index-4qnzda76.js} +0 -0
- package/dist/{index-fsw1ybf3.js → index-d9hq291n.js} +3 -3
- package/dist/{index-bjcjqj46.js → index-m8a44e64.js} +3 -3
- package/dist/{index-5efrpfmj.js → index-qndsr7f0.js} +3 -3
- package/dist/{index-zktz242w.js → index-s47ac8zt.js} +3 -3
- package/dist/{index-56yx8dxp.js → index-tvtacc9a.js} +3 -3
- /package/dist/{index-v1tswj0z.js → index-txg1npmg.js} +0 -0
package/llms/react-keyboard.txt
CHANGED
|
@@ -46,6 +46,8 @@ key hears it first — a widget's own `onKeyDown`, a text field, a popover that
|
|
|
46
46
|
closes on Escape — and keeps it with `preventDefault()` or `stopPropagation()`.
|
|
47
47
|
A layer only gets what nobody nearer wanted, so third-party components keep
|
|
48
48
|
working unchanged.
|
|
49
|
+
One kind of layer may ask for the opposite: a menu over a text field takes the
|
|
50
|
+
arrows before the field does — see [A menu over a text field](#a-menu-over-a-text-field).
|
|
49
51
|
|
|
50
52
|
## Shortcuts and Escape
|
|
51
53
|
|
|
@@ -125,7 +127,7 @@ function ChatList({ chats, openId, open }) {
|
|
|
125
127
|
| Option | Default | Meaning |
|
|
126
128
|
|---|---|---|
|
|
127
129
|
| `items`, `value`, `onChange` | — | the ids in order, the chosen one, and the report of a choice; the zone never holds the value |
|
|
128
|
-
| `mode` | `'select'` | `'select'`: an arrow chooses (`onChange(id, 'arrow')`). `'highlight'`: an arrow only moves focus, Enter chooses (`onChange(id, 'confirm')`) |
|
|
130
|
+
| `mode` | `'select'` | `'select'`: an arrow chooses (`onChange(id, 'arrow')`). `'highlight'`: an arrow only moves focus, Enter chooses (`onChange(id, 'confirm')`). `'focus'`: an arrow only moves focus and the items choose for themselves — see [below](#links-and-buttons-that-choose-for-themselves) |
|
|
129
131
|
| `orientation` | `'vertical'` | which arrows move; the other axis enters and exits |
|
|
130
132
|
| `role` | `'listbox'` | `'listbox'` of options or `'tablist'` of tabs, marked `aria-selected`; `'none'` keeps the items' own semantics (links, buttons) and marks the chosen one `aria-current` |
|
|
131
133
|
| `loop` | `false` | wrap past the ends instead of calling `onBoundary` |
|
|
@@ -134,6 +136,8 @@ function ChatList({ chats, openId, open }) {
|
|
|
134
136
|
| `onBoundary(side)` | — | a move past `'start'` or `'end'`: hand focus to the neighbouring zone |
|
|
135
137
|
| `active` | `true` | whether arrows pressed outside every zone land here |
|
|
136
138
|
| `label` | — | the zone's accessible name |
|
|
139
|
+
| `outside` | `'focus'` | what the first arrow does when focus is in no zone: `'focus'` puts focus on the current item and nothing else; `'move'` moves from it and, in select mode, chooses the neighbour — see [below](#the-first-arrow-from-outside-a-zone) |
|
|
140
|
+
| `ignore(event)` | — | a key that is the application's own right now — return `true` and the zone leaves it whole; see [dragging](#a-zone-inside-a-drag-and-drop-list) |
|
|
137
141
|
| `onReveal(id)` | — | focus went to an item with no element yet — a row of a virtualized list outside the rendered range; scroll it in, and focus lands on it when it mounts |
|
|
138
142
|
|
|
139
143
|
The callbacks return `false` to leave the key to the page, anything else takes
|
|
@@ -146,6 +150,256 @@ axis puts focus on the active zone's current item. To move between zones
|
|
|
146
150
|
(sidebar → table), call `zone.focus(id?)` of the other zone from `onEnter` or
|
|
147
151
|
`onBoundary`. `zone.element` is the zone's element, for a layer scoped to it.
|
|
148
152
|
|
|
153
|
+
## Links and buttons that choose for themselves
|
|
154
|
+
|
|
155
|
+
In `select` and `highlight` mode the zone reports a choice, and in `highlight` it takes
|
|
156
|
+
Enter for it. A list of `<a>` and `<button>` elements has a choice built in: Enter
|
|
157
|
+
clicks the focused one. Taking Enter would replace that with a handler you must
|
|
158
|
+
write to click the element yourself.
|
|
159
|
+
|
|
160
|
+
`mode: 'focus'` is for those lists — a menu, a toolbar, a row of tabs that are
|
|
161
|
+
links. The arrows move focus; Enter, and Space on a button, stay with the focused
|
|
162
|
+
element; the zone reports nothing, so there is no `onChange`. `value` is optional
|
|
163
|
+
and only marks the page you are on (`aria-current` with `role: 'none'`).
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
function Toolbar({ actions }) {
|
|
167
|
+
const zone = useNavZone({
|
|
168
|
+
items: actions.map((action) => action.id),
|
|
169
|
+
mode: 'focus',
|
|
170
|
+
role: 'none',
|
|
171
|
+
orientation: 'horizontal',
|
|
172
|
+
label: 'Formatting',
|
|
173
|
+
})
|
|
174
|
+
return (
|
|
175
|
+
<div {...zone.zoneProps}>
|
|
176
|
+
{actions.map((action) => (
|
|
177
|
+
<button key={action.id} type="button" onClick={action.run} {...zone.itemProps(action.id)}>
|
|
178
|
+
{action.label}
|
|
179
|
+
</button>
|
|
180
|
+
))}
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
In a focus zone Enter is never taken, even when `onEnter` is set: `onEnter` is
|
|
187
|
+
the cross-axis key (→ in a vertical zone).
|
|
188
|
+
|
|
189
|
+
## Props, refs and other libraries
|
|
190
|
+
|
|
191
|
+
Spread the zone's props **last** on an element another library also configures, so
|
|
192
|
+
the roving `tabIndex` is the one that counts. A prop without a value is absent, not
|
|
193
|
+
`undefined`: `role` is there only for a listbox or a tablist, `aria-selected` only
|
|
194
|
+
outside `role: 'none'`, `aria-label` only with a `label`. A `role="button"` the
|
|
195
|
+
element carries from another library, or an `aria-label` you wrote before the
|
|
196
|
+
spread, survives it.
|
|
197
|
+
|
|
198
|
+
`ref` is the one prop two libraries both need. `mergeRefs` makes one callback of
|
|
199
|
+
several refs; spread the zone's props, then replace `ref`:
|
|
200
|
+
|
|
201
|
+
```tsx
|
|
202
|
+
import { mergeRefs, useNavZone } from 'stitchkit/react/keyboard'
|
|
203
|
+
|
|
204
|
+
function Row({ id, zone }) {
|
|
205
|
+
const sortable = useSortable({ id })
|
|
206
|
+
const own = zone.itemProps(id)
|
|
207
|
+
return (
|
|
208
|
+
<li
|
|
209
|
+
{...sortable.attributes}
|
|
210
|
+
{...sortable.listeners}
|
|
211
|
+
{...own}
|
|
212
|
+
ref={mergeRefs(own.ref, sortable.setNodeRef)}
|
|
213
|
+
>
|
|
214
|
+
{id}
|
|
215
|
+
</li>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The same goes for `zone.zoneProps.ref` beside a scroll container's or a measuring
|
|
221
|
+
hook's ref. `mergeRefs` returns a new function on every call and React re-attaches a
|
|
222
|
+
callback ref whose identity changed; a component that renders often keeps the
|
|
223
|
+
result in `useMemo`.
|
|
224
|
+
|
|
225
|
+
`useNavZone` also returns a new object on every render. An effect that should run
|
|
226
|
+
when a menu *opens* depends on that, not on `zone`; keep the latest zone in a ref.
|
|
227
|
+
|
|
228
|
+
## A zone inside a drag-and-drop list
|
|
229
|
+
|
|
230
|
+
A sortable list is a zone whose rows can also be lifted with the keyboard: Space
|
|
231
|
+
lifts a row, the arrows move it, Space drops it. The arrows are then the drag's,
|
|
232
|
+
not the zone's, and a zone that also moves focus on them makes the focus ring jump
|
|
233
|
+
from row to row while the row moves.
|
|
234
|
+
|
|
235
|
+
`ignore` hands the zone a question about every key it would act on. dnd-kit marks
|
|
236
|
+
the lifted activator `aria-pressed="true"` for the length of the drag:
|
|
237
|
+
|
|
238
|
+
```tsx
|
|
239
|
+
const zone = useNavZone({
|
|
240
|
+
items: order,
|
|
241
|
+
mode: 'focus',
|
|
242
|
+
role: 'none',
|
|
243
|
+
ignore: (event) =>
|
|
244
|
+
event.target instanceof Element && event.target.closest('[aria-pressed="true"]') !== null,
|
|
245
|
+
})
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
The zone leaves an ignored key entirely: it does not move, does not take the key and
|
|
249
|
+
does not call `preventDefault`, whether the key came from inside the zone or from
|
|
250
|
+
a page whose focus is in no zone. The test is the application's on purpose. A
|
|
251
|
+
toggle button also carries `aria-pressed="true"`, and a toolbar of toggles must
|
|
252
|
+
keep its arrows; only the application knows whether a pressed element is a drag.
|
|
253
|
+
|
|
254
|
+
## A menu or a popover opened by the pointer
|
|
255
|
+
|
|
256
|
+
A script's `focus()` shows the focus ring when the element that had focus before it
|
|
257
|
+
was showing one — and when nothing had. A menu opened by a right click, with focus
|
|
258
|
+
on `body`, would put a ring on its first item although nobody pressed a key. Decide
|
|
259
|
+
at the moment of opening: in the handler that opens the menu, before anything moves
|
|
260
|
+
focus, ask `openedByKeyboard()`.
|
|
261
|
+
|
|
262
|
+
```tsx
|
|
263
|
+
const byKeyboard = useRef(false)
|
|
264
|
+
const container = useRef<HTMLDivElement>(null)
|
|
265
|
+
|
|
266
|
+
function show() {
|
|
267
|
+
byKeyboard.current = openedByKeyboard() // before the menu takes focus
|
|
268
|
+
setOpen(true)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
useLayoutEffect(() => {
|
|
272
|
+
if (!open) return
|
|
273
|
+
if (byKeyboard.current) zone.focus() // the first item, with its ring
|
|
274
|
+
else container.current?.focus() // the menu itself
|
|
275
|
+
}, [open])
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
`openedByKeyboard()` is whether the element with focus right now matches
|
|
279
|
+
`:focus-visible`; it is `false` on a server and in a browser without that selector.
|
|
280
|
+
Give the container `outline: none`: it only holds focus, and a browser may count a
|
|
281
|
+
focus that starts on `body` as keyboard focus. The first arrow with focus on the
|
|
282
|
+
zone's own element lands on the current item, not one step beyond it, so the menu
|
|
283
|
+
opened by a click is a normal menu from the first key.
|
|
284
|
+
|
|
285
|
+
A focus manager that also puts initial focus on opening fights this for the first
|
|
286
|
+
item. Floating UI's `FloatingFocusManager` documents `initialFocus={-1}` as "ignore
|
|
287
|
+
the initial focus" and `initialFocus={refs.floating}` as focusing the container;
|
|
288
|
+
either leaves the first item to the handler above. (This repository's browser probe
|
|
289
|
+
covers the pattern with a plain element, not Floating UI.)
|
|
290
|
+
|
|
291
|
+
## The first arrow from outside a zone
|
|
292
|
+
|
|
293
|
+
With focus in no zone — right after a click on the page, or on the empty part of a
|
|
294
|
+
screen — the first arrow on an active zone's axis puts focus on its current item.
|
|
295
|
+
That is the safe answer: the person sees where they are before anything changes,
|
|
296
|
+
and a zone whose arrows choose does not change the choice on a key that only
|
|
297
|
+
announced the list.
|
|
298
|
+
|
|
299
|
+
A list that has always answered the first arrow with a change (a chat list whose
|
|
300
|
+
arrows open the next chat) can say so: `outside: 'move'` acts as if focus were
|
|
301
|
+
already on the current item, so the arrow moves from it and, in `select` mode,
|
|
302
|
+
calls `onChange` for the neighbour. It is opt-in and per zone; `ignore` is asked
|
|
303
|
+
first in both.
|
|
304
|
+
|
|
305
|
+
## A panel that keeps its keys around a zone
|
|
306
|
+
|
|
307
|
+
`data-local-keys` marks an area whose keys belong to it rather than to the page:
|
|
308
|
+
layers skip keys pressed inside it, so a section switch on ← and → leaves a
|
|
309
|
+
dropdown panel alone. A zone inside such a panel still has its arrows. The zone is
|
|
310
|
+
the part of the area that handles them, so only a field *inside the zone* — an
|
|
311
|
+
input in one of its rows — keeps its keys from the zone; an area that contains the
|
|
312
|
+
zone, or the zone's own element carrying the mark, does not. ← and → are not a
|
|
313
|
+
vertical zone's keys, so they bubble on to the layers, which skip them in that
|
|
314
|
+
panel: the zone moves, the section does not.
|
|
315
|
+
|
|
316
|
+
## A menu over a text field
|
|
317
|
+
|
|
318
|
+
A layer hears a key after everything closer to it, and a text field is as close as
|
|
319
|
+
it gets: the field acts on an arrow or Enter first. A menu that hangs over the
|
|
320
|
+
field — slash commands, quick replies — keeps focus in the field and needs those
|
|
321
|
+
keys *before* it does, and the field's own Enter (send the message) must not also
|
|
322
|
+
run.
|
|
323
|
+
|
|
324
|
+
`phase: 'capture'` is for that, and for a `local` layer only (any other kind
|
|
325
|
+
refuses it when it mounts): the layer is asked before the element that has focus,
|
|
326
|
+
and a key it takes — `preventDefault` and `stopPropagation` — reaches nothing
|
|
327
|
+
nearer.
|
|
328
|
+
|
|
329
|
+
```tsx
|
|
330
|
+
function CommandMenu({ open, items, pick }) {
|
|
331
|
+
const menu = useRef<HTMLDivElement>(null)
|
|
332
|
+
const [index, setIndex] = useState(0)
|
|
333
|
+
useKeyLayer({
|
|
334
|
+
kind: 'local',
|
|
335
|
+
phase: 'capture',
|
|
336
|
+
fields: 'include', // the key is pressed in the field
|
|
337
|
+
scope: menu, // heard only while the menu is on the page
|
|
338
|
+
active: open,
|
|
339
|
+
onKey: (event) => {
|
|
340
|
+
if (event.key === 'ArrowDown') setIndex((i) => Math.min(i + 1, items.length - 1))
|
|
341
|
+
else if (event.key === 'ArrowUp') setIndex((i) => Math.max(i - 1, 0))
|
|
342
|
+
else if (event.key === 'Enter') pick(items[index])
|
|
343
|
+
else return false // typing, Backspace, everything else stays the field's
|
|
344
|
+
return true
|
|
345
|
+
},
|
|
346
|
+
})
|
|
347
|
+
return open ? <div ref={menu} role="listbox">…</div> : null
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
`fields: 'include'` is needed because the default skips keys pressed in a field;
|
|
352
|
+
`scope` and `active` keep the layer from taking keys when there is no menu. An
|
|
353
|
+
`overlay` above still stops it: a live overlay is a barrier for layers of either
|
|
354
|
+
phase. In `createKeyboardTestBed`, `press` runs the capture phase first and
|
|
355
|
+
stops there when a layer took the key (`event.propagationStopped`).
|
|
356
|
+
|
|
357
|
+
## Moving from a stack of zones of your own
|
|
358
|
+
|
|
359
|
+
A hand-written zone stack and `stitchkit/react/keyboard` name the same ideas
|
|
360
|
+
differently:
|
|
361
|
+
|
|
362
|
+
| Yours | Here |
|
|
363
|
+
|---|---|
|
|
364
|
+
| a flag that arrows choose at once (`instant`) | `mode: 'select'`; `'highlight'` when choosing is expensive |
|
|
365
|
+
| wrapping (often the default) | `loop` — `false` by default; set it on the lists that wrapped |
|
|
366
|
+
| the current section | `active` for which zone takes the first arrow, `scope` for which layer is live |
|
|
367
|
+
| one shared "highlighted id" | each zone's own focused item; read `zone.current` |
|
|
368
|
+
| an attribute that marks areas with their own keys | `data-local-keys` |
|
|
369
|
+
| Escape closing the innermost thing | `useEscapeLayer` — one Escape closes one level |
|
|
370
|
+
|
|
371
|
+
## Testing without a DOM
|
|
372
|
+
|
|
373
|
+
`stitchkit/react/keyboard/testing` has no React and no DOM in it: a window that
|
|
374
|
+
remembers its listener, elements with a parent, `inert` and visibility, and key
|
|
375
|
+
events with `preventDefault`. A test adds the layers a screen declares, presses
|
|
376
|
+
keys on elements and reads who heard what.
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
import { createKeyboardTestBed } from 'stitchkit/react/keyboard/testing'
|
|
380
|
+
|
|
381
|
+
test('Escape closes the dialog and not the page behind it', () => {
|
|
382
|
+
const bed = createKeyboardTestBed()
|
|
383
|
+
const heard: string[] = []
|
|
384
|
+
const page = bed.element()
|
|
385
|
+
const dialog = bed.element()
|
|
386
|
+
bed.stack.add({ kind: 'route', scope: () => page, onKey: () => (heard.push('page'), true) })
|
|
387
|
+
bed.stack.add({ kind: 'overlay', scope: () => dialog, onKey: () => (heard.push('dialog'), true) })
|
|
388
|
+
bed.press('Escape')
|
|
389
|
+
expect(heard).toEqual(['dialog'])
|
|
390
|
+
page.inert = true // the page behind a dialog
|
|
391
|
+
dialog.hidden = true
|
|
392
|
+
bed.press('Escape')
|
|
393
|
+
expect(heard).toEqual(['dialog'])
|
|
394
|
+
})
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
`createNavZone` is the zone without React: build it with the items' fake elements
|
|
398
|
+
(`bed.element({ zone: true })` and its `child()` rows count `focus()` and scrolling),
|
|
399
|
+
call `zone.keyDown(bed.key('ArrowDown', { target: row }), zoneElement)`. A real
|
|
400
|
+
focus ring, scrolling and screen readers are a browser's: this is for the order of
|
|
401
|
+
layers, `scope`/`inert` and the edges of a zone.
|
|
402
|
+
|
|
149
403
|
## What stays in the application
|
|
150
404
|
|
|
151
405
|
The URL, the router, sections and which screen is open. A layer calls the
|
|
@@ -168,13 +422,19 @@ no component, no styles, no router — see the [keyboard guide](../guide/keyboar
|
|
|
168
422
|
|--------|------|---------|
|
|
169
423
|
| `useKeyLayer` | hook | put a layer on the window's one key stack — asked `overlay` → `local` → `route` → `zone` → `global`, innermost first within a kind |
|
|
170
424
|
| `useEscapeLayer` | hook | an `overlay`, `local` or `route` layer for Escape: one press closes one level |
|
|
171
|
-
| `useNavZone` | hook | a list zone — roving tab stop, arrows move DOM focus, select or
|
|
425
|
+
| `useNavZone` | hook | a list zone — roving tab stop, arrows move DOM focus, select, highlight or focus mode, cross-axis enter/exit, boundaries, `ignore` for a key the application is using; props carry no key without a value |
|
|
172
426
|
| `nextZoneIndex` | function | where a move lands in a list of a given length: an index, `'before-start'` or `'after-end'` |
|
|
173
427
|
| `isKeyFieldTarget` | function | whether a key was pressed in a field or a `data-local-keys` area, the test layers use |
|
|
174
|
-
| `KeyLayerOptions` | _type_ | `kind`, `onKey`, `active`, `scope`, `fields` |
|
|
428
|
+
| `KeyLayerOptions` | _type_ | `kind`, `onKey`, `active`, `scope`, `fields`, `phase` — `'capture'` is for a `local` layer only: it hears a key before the element that has focus does, as a menu over a text field must, and a key it takes goes no further |
|
|
429
|
+
| `KeyLayerPhase` | _type_ | `'bubble' \| 'capture'` |
|
|
175
430
|
| `KeyLayerKind` | _type_ | `'overlay' \| 'local' \| 'route' \| 'zone' \| 'global'` |
|
|
176
431
|
| `EscapeLayerOptions` | _type_ | `kind`, `onEscape`, `active`, `scope` |
|
|
177
|
-
| `NavZoneOptions` | _type_ | `items`, `value`, `onChange`, `mode
|
|
432
|
+
| `NavZoneOptions` | _type_ | `items`, `value`, `onChange`, `mode` (`select` or `highlight`), `orientation`, `role`, `loop`, `onEnter`, `onExit`, `onBoundary`, `active`, `outside` (`'focus'` or `'move'`: what the first arrow does when focus is in no zone), `label`, `onReveal`, `ignore` |
|
|
433
|
+
| `FocusZoneOptions` | _type_ | the options of a zone whose items choose for themselves: `mode: 'focus'`, no `onChange`, an optional `value`; the arrows move focus and Enter stays with the focused element |
|
|
434
|
+
| `ZoneOptions` | _type_ | `NavZoneOptions` or `FocusZoneOptions` — what `useNavZone` takes |
|
|
435
|
+
| `ZoneKeyEvent` | _type_ | the key an `ignore` callback is asked about: `key`, modifiers, `target`, `repeat`, `isComposing`, `defaultPrevented` |
|
|
436
|
+
| `mergeRefs` | function | one ref callback for several refs — the zone's `ref` beside a drag handle's or your own; spread the zone's props, then set `ref: mergeRefs(props.ref, other)` |
|
|
437
|
+
| `openedByKeyboard` | function | whether the element with focus right now shows a focus ring — call it in the handler that opens a menu, before focus moves, to decide whether the first item takes focus |
|
|
178
438
|
| `NavZone` | _type_ | what `useNavZone` returns — `zoneProps`, `itemProps(id)`, `element`, `current`, `focus(id?)` |
|
|
179
439
|
| `NavZoneProps` / `NavZoneItemProps` | _type_ | the props spread on the zone's element and on each item |
|
|
180
440
|
| `NextZoneIndexInput` | _type_ | `length`, `index`, `move`, `loop` |
|
package/llms/react.txt
CHANGED
|
@@ -144,7 +144,7 @@ Browser- and server-render-safe React data-layer helpers. Needs the
|
|
|
144
144
|
| `EntityCacheHandlers` | _type_ | the `{ created, updated, deleted }` handlers it returns |
|
|
145
145
|
| `EntityCacheEvent` | _type_ | discriminated created/updated/deleted input for dynamic cache keys |
|
|
146
146
|
| `EntityCacheKey` | _type_ | static `QueryKey` or event-aware key factory |
|
|
147
|
-
| `EntityCacheListConfig` | _type_ | list shape, scoped key, insertion/missing-update policy and
|
|
147
|
+
| `EntityCacheListConfig` | _type_ | list shape, scoped key, insertion/missing-update policy and one ordering: `compare` (the whole array is sorted again after a create or update) or `insertCompare` (only a row entering the list is placed; every other row stays, and an updated row keeps its place) |
|
|
148
148
|
| `EntityCacheListShape` | _type_ | `array \| paginated \| infinite-array \| infinite-paginated` |
|
|
149
149
|
| `DeletedPayload` | _type_ | a `deleted` event payload — the entity or a bare `{ id }` |
|
|
150
150
|
| `CursorQueryConfig` | _type_ | config for `createCursorQuery` |
|
package/llms/server.3.txt
CHANGED
|
@@ -868,6 +868,37 @@ deleted payload without guessing. The same resolved detail key drives the
|
|
|
868
868
|
`isFresh` echo guard. Shape checks also leave neighbouring detail caches alone
|
|
869
869
|
when a list key is intentionally used as a partial query-key prefix.
|
|
870
870
|
|
|
871
|
+
### When only a new row needs an order
|
|
872
|
+
|
|
873
|
+
`compare` is the order the row itself determines: the array is sorted again after
|
|
874
|
+
every create and update, so a rename that moves a row moves it in the cache too.
|
|
875
|
+
Some lists are ordered by facts the row does not carry — the projects a person
|
|
876
|
+
owns first, then the ones they administer, then the rest by name, where the role
|
|
877
|
+
is not in the row for a platform administrator who belongs to none of them. No
|
|
878
|
+
`compare` can reproduce that, and sorting every row with a wrong one scrambles
|
|
879
|
+
the list the server ordered.
|
|
880
|
+
|
|
881
|
+
`insertCompare` answers a smaller question: where does a row that is *entering*
|
|
882
|
+
the list go?
|
|
883
|
+
|
|
884
|
+
```ts
|
|
885
|
+
list: {
|
|
886
|
+
key: ['projects'],
|
|
887
|
+
shape: 'array',
|
|
888
|
+
createAt: 'end',
|
|
889
|
+
updateMissing: 'insert',
|
|
890
|
+
insertCompare: (incoming, existing) => incoming.name.localeCompare(existing.name),
|
|
891
|
+
}
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
A new row goes before the first row it sorts ahead of; a tie goes by `createAt`
|
|
895
|
+
(`'start'`: before equal rows, `'end'`: after them). On a list already in that
|
|
896
|
+
order the result is the one `compare` would give. On a list that is not, no other
|
|
897
|
+
row moves, and a row that is already in the list is replaced where it stands.
|
|
898
|
+
A paged list places the row within the page `createAt` selects, and leaves the
|
|
899
|
+
other pages as they were. `compare` and `insertCompare` are two answers to one
|
|
900
|
+
question: a list takes one of them, and `createEntityCacheHandlers` refuses both.
|
|
901
|
+
|
|
871
902
|
The helper deliberately does **not** flatten pages, infer filter semantics,
|
|
872
903
|
derive a sort order or replace arbitrary `setQueryData` logic. Those remain
|
|
873
904
|
declared application policies; Stitchkit applies their generic cache mechanics.
|
package/llms/stitchkit.2.txt
CHANGED
|
@@ -16,6 +16,124 @@ Also read:
|
|
|
16
16
|
# Guide: Typed client (docs/guide/client.md) (continued)
|
|
17
17
|
==============================================================================
|
|
18
18
|
|
|
19
|
+
## Chunked uploads
|
|
20
|
+
|
|
21
|
+
A file larger than one request goes as three calls of the application's own
|
|
22
|
+
contract — open, parts, finish — driven by `uploadInChunks` on the client and
|
|
23
|
+
held by `createChunkSpool` (`stitchkit/files`) on the server. The contract stays
|
|
24
|
+
the application's: its fields, its limits, what happens to the finished file.
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
// shared: one part size for both halves
|
|
28
|
+
export const CHUNK_BYTES = 4 * 1024 * 1024
|
|
29
|
+
|
|
30
|
+
// contract
|
|
31
|
+
init: {
|
|
32
|
+
method: 'POST', path: '/uploads', desc: 'Open an upload',
|
|
33
|
+
input: z.object({ uploadId: z.string(), totalBytes: z.number(), chunkCount: z.number(), name: z.string() }),
|
|
34
|
+
output: z.object({ finished: Accepted.optional() }),
|
|
35
|
+
},
|
|
36
|
+
chunk: {
|
|
37
|
+
method: 'PUT', path: '/uploads/:uploadId/chunks/:index', desc: 'Send one part',
|
|
38
|
+
params: z.object({ uploadId: z.string(), index: z.coerce.number() }),
|
|
39
|
+
multipart: { files: { bytes: {} } },
|
|
40
|
+
output: z.object({ state: z.enum(['stored', 'repeated']) }),
|
|
41
|
+
},
|
|
42
|
+
finalize: {
|
|
43
|
+
method: 'POST', path: '/uploads/:uploadId/finalize', desc: 'Assemble the file',
|
|
44
|
+
params: z.object({ uploadId: z.string() }),
|
|
45
|
+
output: Accepted,
|
|
46
|
+
},
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// server
|
|
51
|
+
import { createChunkSpool } from 'stitchkit/files'
|
|
52
|
+
|
|
53
|
+
const spool = createChunkSpool({
|
|
54
|
+
directory: env.UPLOAD_SPOOL_DIR,
|
|
55
|
+
chunkBytes: CHUNK_BYTES,
|
|
56
|
+
maxFileBytes: 1024 * 1024 * 1024,
|
|
57
|
+
meta: z.object({ name: z.string() }),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
implement(contract, {
|
|
61
|
+
init: async ({ input, ctx }) => {
|
|
62
|
+
const done = await findFinished(input.uploadId) // the app's own record
|
|
63
|
+
if (done) return { finished: done }
|
|
64
|
+
await spool.open({ owner: ctx.user.id, ...input, meta: { name: input.name } })
|
|
65
|
+
return {}
|
|
66
|
+
},
|
|
67
|
+
chunk: async ({ params, files, ctx }) => ({
|
|
68
|
+
state: await spool.put({ owner: ctx.user.id, ...params, bytes: files.bytes }),
|
|
69
|
+
}),
|
|
70
|
+
finalize: async ({ params, ctx }) => {
|
|
71
|
+
const key = { owner: ctx.user.id, uploadId: params.uploadId }
|
|
72
|
+
const file = await spool.assemble(key) // file.stream(), file.chunkPaths, file.meta
|
|
73
|
+
const accepted = await store(file)
|
|
74
|
+
await spool.discard(key)
|
|
75
|
+
return accepted
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// client — Bun, Node, a browser or React Native
|
|
82
|
+
import { uploadInChunks } from 'stitchkit'
|
|
83
|
+
|
|
84
|
+
const accepted = await uploadInChunks({
|
|
85
|
+
file,
|
|
86
|
+
chunkBytes: CHUNK_BYTES,
|
|
87
|
+
signal,
|
|
88
|
+
onProgress: ({ sentBytes, totalBytes }) => draw(sentBytes / totalBytes),
|
|
89
|
+
init: async (start, options) => {
|
|
90
|
+
const opened = await api.uploads.init.withOptions({ ...start, name: file.name }, options)
|
|
91
|
+
return opened.finished ? { finished: opened.finished } : undefined
|
|
92
|
+
},
|
|
93
|
+
chunk: (part, options) => api.uploads.chunk.withOptions(part, options),
|
|
94
|
+
finalize: (upload, options) => api.uploads.finalize.withOptions(upload, options),
|
|
95
|
+
})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The rules the two halves share:
|
|
99
|
+
|
|
100
|
+
- **The client mints the upload id** (default `crypto.randomUUID()`, fresh per
|
|
101
|
+
call), so a repeated `init` is the same upload. The spool keys it by `owner`
|
|
102
|
+
too — the same id from another owner is another upload. To resume after the
|
|
103
|
+
app restarts instead of sending the file again, pass a stable `uploadId` —
|
|
104
|
+
the id of the app's own job or attempt.
|
|
105
|
+
- **The file is anything with `size` and `slice(start, end)`** (`ChunkSource`).
|
|
106
|
+
A `Blob` is one; so is a platform file that is not a DOM `Blob`, such as an
|
|
107
|
+
Expo `File`, passed without a cast. A part's `bytes` is what `slice`
|
|
108
|
+
returns, so the part call hands it to the client as the multipart file.
|
|
109
|
+
- **A part is idempotent.** The spool keeps a size+sha256 receipt per index:
|
|
110
|
+
the same bytes again answer `'repeated'`, other bytes `UPLOAD_CONFLICT`
|
|
111
|
+
(409). So the driver repeats `init` and a part on a failure that may pass — no
|
|
112
|
+
answer (network, timeout), `429`, `5xx` — up to `retries` times (default 3)
|
|
113
|
+
with a doubling pause from `retryDelayMs` (default 1 s). A refusal is final.
|
|
114
|
+
- **`finalize` is never repeated.** After it the application owns the file and
|
|
115
|
+
the parts are gone; a lost answer is recovered by `init` returning
|
|
116
|
+
`{ finished }`, which the driver hands back without sending a byte.
|
|
117
|
+
- **Cancellation is checked between parts**, so a cancelled upload never
|
|
118
|
+
reaches `finalize`.
|
|
119
|
+
- **Every part but the last is exactly `chunkBytes`** and `chunkCount` must add
|
|
120
|
+
up; the spool refuses anything else with a 4xx `AppError`
|
|
121
|
+
(`ChunkSpoolErrorCode`), which a handler can let through as the client's
|
|
122
|
+
typed error. An application that maps error codes onto its own must map
|
|
123
|
+
these too — `isChunkSpoolErrorCode` / `CHUNK_SPOOL_ERROR_CODES` from
|
|
124
|
+
`stitchkit/files` — or a refusal reaches its client as an unknown 500.
|
|
125
|
+
- **Progress counts file bytes**, inside a part too — the driver passes
|
|
126
|
+
`onUploadProgress` into the part call, so the [upload
|
|
127
|
+
progress](#upload-progress) route of the client applies. A repeated part
|
|
128
|
+
counts again from its start.
|
|
129
|
+
- The first writer of a part wins across processes: its data file is named by
|
|
130
|
+
its hash and its receipt is published by an exclusive link. `sweep()` removes
|
|
131
|
+
uploads untouched for `staleAfterMs` (default 24 h); run it on a schedule.
|
|
132
|
+
|
|
133
|
+
Part size is a latency decision: every part costs a round trip, so on a
|
|
134
|
+
200 ms link a smaller part buys smoother progress at the price of a slower
|
|
135
|
+
upload.
|
|
136
|
+
|
|
19
137
|
## Contract URL builders
|
|
20
138
|
|
|
21
139
|
Browser-native consumers such as `<img src>`, downloads and navigation need a
|
package/llms/stitchkit.txt
CHANGED
|
@@ -76,6 +76,7 @@ own, recorded as an ADR.
|
|
|
76
76
|
| `stitchkit/files/packaging` | build time (Bun or Node) | evolving | installed native asset graph, integrity, Bun packaging plugin and the artifact loader check; no runtime install policy |
|
|
77
77
|
| `stitchkit/files` | server (Bun or Node) | stable | peer-free managed local-file boundary |
|
|
78
78
|
| `stitchkit/telegram` | server (Bun or Node) | evolving | peer-free Telegram platform primitives — Mini App `initData` verification, send-failure classification, one outbound budget per bot token with weighted albums and shared 429 pauses, resumable broadcasts, the operator channel with dedupe, a Bot API transport that separates a request that never left from an unknown outcome, local Bot API files, webhook ownership and durable webhook update intake |
|
|
79
|
+
| `stitchkit/telegram/grammy` | server (Bun or Node) | evolving | the token's outbound budget as one grammY client transformer — a 429 that comes back as a response pauses the token, and a cancelled wait is a request that never left |
|
|
79
80
|
| `stitchkit/telegram/html` | browser and server | evolving | Telegram's HTML as a tree — clean any markup to what Telegram accepts, cut long text into valid parts by what the reader sees, check markup before it is sent |
|
|
80
81
|
| `stitchkit/telegram/screens` | server (Bun or Node) | evolving | a bot's menus as declared screens over grammY — typed paths, actions and input, message reconciliation, state in any `StorageAdapter`, a test chat |
|
|
81
82
|
| `stitchkit/tracking` | browser **and** server | evolving | visitor-tracking mechanics — `createTrackingClient`, the tab-shared outbox, the page-leave beacon, attribution, the contract factory; no event vocabulary, no React |
|
|
@@ -90,6 +91,7 @@ own, recorded as an ADR.
|
|
|
90
91
|
| `stitchkit/declaration` | browser + build and deployment tooling (Bun or Node) | evolving | `ProjectDeclarationSchema` — the one machine-readable statement a repository makes about itself |
|
|
91
92
|
| `stitchkit/react` | browser + server rendering | stable | `createCursorQuery`, `createCacheBridge`, QueryClient and `ApiError` retry policy |
|
|
92
93
|
| `stitchkit/react/keyboard` | browser | evolving | which part of a screen gets a key — ordered layers, one Escape per level, list zones with real focus; see [keyboard guide](keyboard.md) |
|
|
94
|
+
| `stitchkit/react/keyboard/testing` | server (Bun or Node) | evolving | the keyboard layers and zones without a DOM — fakes for the window, elements and key events beside the two engines, to test the order of layers, `scope`/`inert` and the edges of a zone in milliseconds |
|
|
93
95
|
| `stitchkit/agent-runtime` | server | evolving<br>_redefined in 24 of the 59 minors since 0.56.2, most recently 0.113.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
|
|
94
96
|
| `stitchkit/agent-runtime/sandbox` | server | evolving | optional Linux Bubblewrap sessions, durable workspaces and a host HTTP credential gateway; see [sandbox guide](sandbox.md) |
|
|
95
97
|
| `stitchkit/agent-runtime/testing` | tests on Bun or Node | evolving | credential-free replay, scripted provider faults, deterministic race controls and the agent-store conformance kit |
|
|
@@ -215,6 +217,7 @@ map — feature → packages:
|
|
|
215
217
|
| MCP Apps UI widgets | `@modelcontextprotocol/ext-apps` |
|
|
216
218
|
| React data layer (`stitchkit/react`) | `@tanstack/react-query` `react-query-kit` |
|
|
217
219
|
| Keyboard layers and list zones (`stitchkit/react/keyboard`) | `react` |
|
|
220
|
+
| Keyboard test bed (`stitchkit/react/keyboard/testing`) | — (peer-free) |
|
|
218
221
|
| MaxMind GeoIP (`stitchkit/geo`) | `maxmind` |
|
|
219
222
|
| Browser OAuth client (`stitchkit/oauth`) | — |
|
|
220
223
|
| Google OIDC verifier (`stitchkit/google`) | `google-auth-library` |
|
|
@@ -224,6 +227,7 @@ map — feature → packages:
|
|
|
224
227
|
| grammY lifecycle adapters (`stitchkit/application/grammy`) | `grammy` |
|
|
225
228
|
| Telegram platform primitives (`stitchkit/telegram`) | — (peer-free) |
|
|
226
229
|
| Telegram message markup (`stitchkit/telegram/html`) | — (peer-free) |
|
|
230
|
+
| Telegram grammY client transformer (`stitchkit/telegram/grammy`) | `grammy` (types only; the bot brings it) |
|
|
227
231
|
| Telegram screens (`stitchkit/telegram/screens`) | `grammy` (types only; the bot brings it) |
|
|
228
232
|
| OpenTelemetry gauges (`stitchkit/application/opentelemetry`) | `@opentelemetry/api` |
|
|
229
233
|
|
|
@@ -789,121 +793,3 @@ const tenantApi = createClients({ users, posts }, http, {
|
|
|
789
793
|
Every method now requires `tenantId`, and the callback sees it as a `string`.
|
|
790
794
|
The batch form delegates to the same single-contract client runtime, including
|
|
791
795
|
HTTP exposure filtering, multipart, raw responses and output validation.
|
|
792
|
-
|
|
793
|
-
## Chunked uploads
|
|
794
|
-
|
|
795
|
-
A file larger than one request goes as three calls of the application's own
|
|
796
|
-
contract — open, parts, finish — driven by `uploadInChunks` on the client and
|
|
797
|
-
held by `createChunkSpool` (`stitchkit/files`) on the server. The contract stays
|
|
798
|
-
the application's: its fields, its limits, what happens to the finished file.
|
|
799
|
-
|
|
800
|
-
```ts
|
|
801
|
-
// shared: one part size for both halves
|
|
802
|
-
export const CHUNK_BYTES = 4 * 1024 * 1024
|
|
803
|
-
|
|
804
|
-
// contract
|
|
805
|
-
init: {
|
|
806
|
-
method: 'POST', path: '/uploads', desc: 'Open an upload',
|
|
807
|
-
input: z.object({ uploadId: z.string(), totalBytes: z.number(), chunkCount: z.number(), name: z.string() }),
|
|
808
|
-
output: z.object({ finished: Accepted.optional() }),
|
|
809
|
-
},
|
|
810
|
-
chunk: {
|
|
811
|
-
method: 'PUT', path: '/uploads/:uploadId/chunks/:index', desc: 'Send one part',
|
|
812
|
-
params: z.object({ uploadId: z.string(), index: z.coerce.number() }),
|
|
813
|
-
multipart: { files: { bytes: {} } },
|
|
814
|
-
output: z.object({ state: z.enum(['stored', 'repeated']) }),
|
|
815
|
-
},
|
|
816
|
-
finalize: {
|
|
817
|
-
method: 'POST', path: '/uploads/:uploadId/finalize', desc: 'Assemble the file',
|
|
818
|
-
params: z.object({ uploadId: z.string() }),
|
|
819
|
-
output: Accepted,
|
|
820
|
-
},
|
|
821
|
-
```
|
|
822
|
-
|
|
823
|
-
```ts
|
|
824
|
-
// server
|
|
825
|
-
import { createChunkSpool } from 'stitchkit/files'
|
|
826
|
-
|
|
827
|
-
const spool = createChunkSpool({
|
|
828
|
-
directory: env.UPLOAD_SPOOL_DIR,
|
|
829
|
-
chunkBytes: CHUNK_BYTES,
|
|
830
|
-
maxFileBytes: 1024 * 1024 * 1024,
|
|
831
|
-
meta: z.object({ name: z.string() }),
|
|
832
|
-
})
|
|
833
|
-
|
|
834
|
-
implement(contract, {
|
|
835
|
-
init: async ({ input, ctx }) => {
|
|
836
|
-
const done = await findFinished(input.uploadId) // the app's own record
|
|
837
|
-
if (done) return { finished: done }
|
|
838
|
-
await spool.open({ owner: ctx.user.id, ...input, meta: { name: input.name } })
|
|
839
|
-
return {}
|
|
840
|
-
},
|
|
841
|
-
chunk: async ({ params, files, ctx }) => ({
|
|
842
|
-
state: await spool.put({ owner: ctx.user.id, ...params, bytes: files.bytes }),
|
|
843
|
-
}),
|
|
844
|
-
finalize: async ({ params, ctx }) => {
|
|
845
|
-
const key = { owner: ctx.user.id, uploadId: params.uploadId }
|
|
846
|
-
const file = await spool.assemble(key) // file.stream(), file.chunkPaths, file.meta
|
|
847
|
-
const accepted = await store(file)
|
|
848
|
-
await spool.discard(key)
|
|
849
|
-
return accepted
|
|
850
|
-
},
|
|
851
|
-
})
|
|
852
|
-
```
|
|
853
|
-
|
|
854
|
-
```ts
|
|
855
|
-
// client — Bun, Node, a browser or React Native
|
|
856
|
-
import { uploadInChunks } from 'stitchkit'
|
|
857
|
-
|
|
858
|
-
const accepted = await uploadInChunks({
|
|
859
|
-
file,
|
|
860
|
-
chunkBytes: CHUNK_BYTES,
|
|
861
|
-
signal,
|
|
862
|
-
onProgress: ({ sentBytes, totalBytes }) => draw(sentBytes / totalBytes),
|
|
863
|
-
init: async (start, options) => {
|
|
864
|
-
const opened = await api.uploads.init.withOptions({ ...start, name: file.name }, options)
|
|
865
|
-
return opened.finished ? { finished: opened.finished } : undefined
|
|
866
|
-
},
|
|
867
|
-
chunk: (part, options) => api.uploads.chunk.withOptions(part, options),
|
|
868
|
-
finalize: (upload, options) => api.uploads.finalize.withOptions(upload, options),
|
|
869
|
-
})
|
|
870
|
-
```
|
|
871
|
-
|
|
872
|
-
The rules the two halves share:
|
|
873
|
-
|
|
874
|
-
- **The client mints the upload id** (default `crypto.randomUUID()`, fresh per
|
|
875
|
-
call), so a repeated `init` is the same upload. The spool keys it by `owner`
|
|
876
|
-
too — the same id from another owner is another upload. To resume after the
|
|
877
|
-
app restarts instead of sending the file again, pass a stable `uploadId` —
|
|
878
|
-
the id of the app's own job or attempt.
|
|
879
|
-
- **The file is anything with `size` and `slice(start, end)`** (`ChunkSource`).
|
|
880
|
-
A `Blob` is one; so is a platform file that is not a DOM `Blob`, such as an
|
|
881
|
-
Expo `File`, passed without a cast. A part's `bytes` is what `slice`
|
|
882
|
-
returns, so the part call hands it to the client as the multipart file.
|
|
883
|
-
- **A part is idempotent.** The spool keeps a size+sha256 receipt per index:
|
|
884
|
-
the same bytes again answer `'repeated'`, other bytes `UPLOAD_CONFLICT`
|
|
885
|
-
(409). So the driver repeats `init` and a part on a failure that may pass — no
|
|
886
|
-
answer (network, timeout), `429`, `5xx` — up to `retries` times (default 3)
|
|
887
|
-
with a doubling pause from `retryDelayMs` (default 1 s). A refusal is final.
|
|
888
|
-
- **`finalize` is never repeated.** After it the application owns the file and
|
|
889
|
-
the parts are gone; a lost answer is recovered by `init` returning
|
|
890
|
-
`{ finished }`, which the driver hands back without sending a byte.
|
|
891
|
-
- **Cancellation is checked between parts**, so a cancelled upload never
|
|
892
|
-
reaches `finalize`.
|
|
893
|
-
- **Every part but the last is exactly `chunkBytes`** and `chunkCount` must add
|
|
894
|
-
up; the spool refuses anything else with a 4xx `AppError`
|
|
895
|
-
(`ChunkSpoolErrorCode`), which a handler can let through as the client's
|
|
896
|
-
typed error. An application that maps error codes onto its own must map
|
|
897
|
-
these too — `isChunkSpoolErrorCode` / `CHUNK_SPOOL_ERROR_CODES` from
|
|
898
|
-
`stitchkit/files` — or a refusal reaches its client as an unknown 500.
|
|
899
|
-
- **Progress counts file bytes**, inside a part too — the driver passes
|
|
900
|
-
`onUploadProgress` into the part call, so the [upload
|
|
901
|
-
progress](#upload-progress) route of the client applies. A repeated part
|
|
902
|
-
counts again from its start.
|
|
903
|
-
- The first writer of a part wins across processes: its data file is named by
|
|
904
|
-
its hash and its receipt is published by an exclusive link. `sweep()` removes
|
|
905
|
-
uploads untouched for `staleAfterMs` (default 24 h); run it on a schedule.
|
|
906
|
-
|
|
907
|
-
Part size is a latency decision: every part costs a round trip, so on a
|
|
908
|
-
200 ms link a smaller part buys smoother progress at the price of a slower
|
|
909
|
-
upload.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# stitchkit/telegram/grammy — stitchkit docs slice
|
|
2
|
+
|
|
3
|
+
> Generated from the `docs/` tree by `bun run gen:llms`. The index of all slices is
|
|
4
|
+
> `llms.txt` in this package.
|
|
5
|
+
|
|
6
|
+
Contains: API reference: `stitchkit/telegram/grammy` (docs/api/reference.md)
|
|
7
|
+
|
|
8
|
+
==============================================================================
|
|
9
|
+
# API reference: `stitchkit/telegram/grammy` (docs/api/reference.md)
|
|
10
|
+
==============================================================================
|
|
11
|
+
|
|
12
|
+
## `stitchkit/telegram/grammy`
|
|
13
|
+
|
|
14
|
+
The token's outbound budget as one grammY client transformer. Server-only, Bun
|
|
15
|
+
or Node; `grammy` appears in the declarations and is never imported at run time —
|
|
16
|
+
the bot brings it. → ADR 0274 — [guide](../guide/telegram.md#one-token-one-outbound-budget)
|
|
17
|
+
|
|
18
|
+
| Export | Kind | Summary |
|
|
19
|
+
|--------|------|---------|
|
|
20
|
+
| `telegramBudgetTransformer` | function | `bot.api.config.use(telegramBudgetTransformer(budget))`: every Bot API call runs under the token's `telegramOutboundBudget`; a 429 that comes back as a response pauses the token, a cancellation before the request was handed on throws `TelegramNotDispatchedError`, a cancellation in flight stays the client's own error; it never retries |
|
|
21
|
+
|
|
22
|
+
---
|