@camstack/types 1.2.245 → 1.2.246

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.
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Addon→addon **byte transport** (D613) — the facility that used to not exist.
3
+ *
4
+ * ## What this replaces
5
+ *
6
+ * An addon could SERVE bytes ({@link AddonDataPlane}) and it could CALL another
7
+ * addon ({@link AddonContext.api}), and neither of those reaches the other
8
+ * addon's bytes:
9
+ *
10
+ * - the data-plane listener binds `127.0.0.1` behind a per-listener secret that
11
+ * **only the hub** presents, and the hub's `/addon/<id>/…` proxy in front of
12
+ * it takes a session cookie, a `cst_` token scoped `{type:'addon'}` or a JWT
13
+ * — an addon holds none of those;
14
+ * - `ctx.api.<cap>.subscribe` is refused on an addon caller by design, so a
15
+ * streaming cap method is not available either;
16
+ * - a unary cap method CAN carry base64, and that is the shape this facility
17
+ * exists to stop: the payload is held whole (~1.33×) in the producer AND in
18
+ * the caller, on a hub this repo has already OOM'd (D9/D18). Every such
19
+ * method has therefore had to carry a byte bound, and
20
+ * `videoclips.readClipBytes` refused a long `high` twin at 50 MiB because of
21
+ * it.
22
+ *
23
+ * The repo worked around the gap TWICE, identically: a cap call mints a URL
24
+ * carrying a token and the consumer dials it directly — the broker's restreams
25
+ * (`getStreamWithCodec`, dialled by the recorder and HomeKit) and the clip dial
26
+ * (`videoclips.dialClipStream`, D597). Each pair re-built an `http.createServer`,
27
+ * a token map, a TTL, a sweep, a one-shot rule, an outstanding bound, a
28
+ * backpressure gate and a consumer-side opener. This facility is that habit,
29
+ * promoted: the mechanism is the framework's, the CONSENT stays the producer's.
30
+ *
31
+ * ## The shape, and why it is this one
32
+ *
33
+ * **Nothing is reachable that was not OFFERED.** There is no
34
+ * `dial(addonId, path)`: a consumer cannot address a producer at all. A
35
+ * producer calls {@link AddonPeerBytes.offer} — usually from inside a cap
36
+ * method it already gates — and gets back a {@link PeerBytesTicket} it may hand
37
+ * to the caller. One offer is ONE body and ONE take. That is the whole
38
+ * authority model, and it is deliberately the same one the two precedents
39
+ * already use, because the alternative (the hub handing a consumer the
40
+ * producer's facility secret) would replace a per-request consent with a
41
+ * standing one, and would hand over a credential good for every prefix that
42
+ * addon serves.
43
+ *
44
+ * **The hub's secret is not touched.** This is a SECOND listener, with its own
45
+ * per-offer tokens. The property "only the hub presents the data-plane secret"
46
+ * is preserved by leaving it alone rather than by widening it.
47
+ *
48
+ * **Same host only, refused by name across a node boundary.** The listener
49
+ * binds loopback, so a ticket minted on `little-unraid` names a URL that means
50
+ * something else entirely on the hub. Every ticket therefore carries the
51
+ * {@link PeerBytesTicket.hostNodeId} it was minted on, and
52
+ * {@link AddonPeerBytes.open} refuses `cross-node` when that is not this
53
+ * process's host — it never dials, and it never silently reaches a DIFFERENT
54
+ * process that happens to hold that port. Device providers run on agents, so
55
+ * this is a real branch and not a theoretical one. Widening it means exposing
56
+ * an addon's listener on a routable interface, which is a security decision for
57
+ * the operator and not a refactor.
58
+ *
59
+ * **This is not a frame pipe.** One offer carries ONE body, taken once, with a
60
+ * TTL measured in seconds and a bound on outstanding offers. Media crosses a
61
+ * process boundary here the way D9/D18 already allow: on demand, compressed, by
62
+ * handle. A caller that minted an offer per frame would exhaust the outstanding
63
+ * bound inside a second and be refused by name.
64
+ */
65
+ import type { IncomingMessage, ServerResponse } from 'node:http';
66
+ import type { Readable } from 'node:stream';
67
+ import { z } from 'zod';
68
+ /**
69
+ * How long a minted ticket stays takeable.
70
+ *
71
+ * The consumer's dial is the next thing that happens after the cap call that
72
+ * minted it returns — measured well under 100 ms on the same host for the clip
73
+ * dial this is generalised from. Thirty seconds covers a busy hub and is short
74
+ * enough that a ticket which leaked into a log is worth nothing by the time
75
+ * anybody reads it.
76
+ */
77
+ export declare const PEER_BYTES_TICKET_TTL_MS = 30000;
78
+ /**
79
+ * Tickets minted and not yet taken or expired, per addon process.
80
+ *
81
+ * A bound, not a budget: it exists so a caller that mints and never dials
82
+ * cannot grow a map without limit, and so a per-frame misuse of this facility
83
+ * is refused by name rather than working slowly.
84
+ */
85
+ export declare const PEER_BYTES_MAX_OUTSTANDING = 64;
86
+ /**
87
+ * How long the producer waits on a consumer that has stopped reading before it
88
+ * cuts the body.
89
+ *
90
+ * D447: a raw HTTP stream reads no chunk the socket has not taken. The producer
91
+ * honours `write()`'s return and awaits `drain`; a peer that never drains is
92
+ * a peer that is gone, and holding the body for it is the unbounded buffer this
93
+ * whole facility exists to avoid.
94
+ */
95
+ export declare const PEER_BYTES_STALL_MS = 15000;
96
+ /** Bytes handed to the socket per `write()` when the producer holds a buffer. */
97
+ export declare const PEER_BYTES_CHUNK_BYTES: number;
98
+ /** The wire shape of {@link PeerBytesTicket} — see the type for what it is. */
99
+ export declare const PeerBytesTicketSchema: z.ZodObject<{
100
+ url: z.ZodString;
101
+ hostNodeId: z.ZodString;
102
+ expiresAtMs: z.ZodNumber;
103
+ declaredBytes: z.ZodNullable<z.ZodNumber>;
104
+ }, z.core.$strip>;
105
+ /**
106
+ * A one-shot handle on ONE body, on ONE host.
107
+ *
108
+ * Travels back to the consumer as the answer of the cap method that minted it,
109
+ * over the trusted hub↔addon channel — the same route `ClipStreamDialSchema`
110
+ * and the restream URLs already travel. It is a bearer credential with a
111
+ * seconds-long life: never persist one, never log the `url`.
112
+ *
113
+ * One shape, one definition: the wire schema IS the type (`z.infer`), so a cap
114
+ * that carries a ticket and the facility that mints one cannot drift.
115
+ */
116
+ export type PeerBytesTicket = z.infer<typeof PeerBytesTicketSchema>;
117
+ export type PeerBytesOfferRefusalCode =
118
+ /** {@link PEER_BYTES_MAX_OUTSTANDING} tickets are already outstanding. */
119
+ 'offer-exhausted'
120
+ /** The loopback listener could not bind. */
121
+ | 'listener-failed';
122
+ export type PeerBytesOfferResult = {
123
+ readonly kind: 'ticket';
124
+ readonly ticket: PeerBytesTicket;
125
+ } | {
126
+ readonly kind: 'refused';
127
+ readonly code: PeerBytesOfferRefusalCode;
128
+ readonly detail: string;
129
+ };
130
+ /** What the producer is publishing, and what to call it in the log. */
131
+ export interface PeerBytesOfferOptions {
132
+ /**
133
+ * The body, as a real Node handler — `res.writeHead(...)` then the producer's
134
+ * own writes. Use it when the producer streams something it is generating;
135
+ * for the two common shapes prefer {@link AddonPeerBytes.offerBuffer} and
136
+ * {@link AddonPeerBytes.offerFile}, which already honour `write()`'s return,
137
+ * cut a peer that never drains and log every exit.
138
+ */
139
+ readonly handler: PeerBytesHandler;
140
+ /** What this body IS, for the log line. Not a filename; a kind. */
141
+ readonly label: string;
142
+ /** The camera this body belongs to. Every log line about a device carries it. */
143
+ readonly deviceId?: number;
144
+ /** See {@link PeerBytesTicket.declaredBytes}. */
145
+ readonly declaredBytes?: number;
146
+ readonly ttlMs?: number;
147
+ }
148
+ export type PeerBytesHandler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
149
+ /** A buffer the producer already holds, written out in bounded chunks. */
150
+ export interface PeerBytesBufferOffer {
151
+ readonly bytes: Buffer;
152
+ readonly contentType: string;
153
+ readonly label: string;
154
+ readonly deviceId?: number;
155
+ readonly ttlMs?: number;
156
+ }
157
+ /** A file on the producer's own disk, streamed — nothing is read into memory. */
158
+ export interface PeerBytesFileOffer {
159
+ readonly path: string;
160
+ readonly contentType: string;
161
+ readonly label: string;
162
+ readonly deviceId?: number;
163
+ readonly ttlMs?: number;
164
+ }
165
+ export type PeerBytesOpenRefusalCode =
166
+ /** The ticket was minted on another HOST; loopback does not cross it. */
167
+ 'cross-node'
168
+ /** Its TTL ran out before the dial. */
169
+ | 'ticket-expired'
170
+ /** Not a URL this facility mints. Nothing was dialled. */
171
+ | 'ticket-malformed'
172
+ /** The dial itself failed — no listener, connection refused, socket error. */
173
+ | 'unreachable'
174
+ /** The producer answered, and its answer was not a body (`dial-used`, 404, 5xx). */
175
+ | 'producer-refused';
176
+ export type PeerBytesOpenResult = {
177
+ readonly kind: 'body';
178
+ readonly body: PeerBytesBody;
179
+ } | {
180
+ readonly kind: 'refused';
181
+ readonly code: PeerBytesOpenRefusalCode;
182
+ readonly detail: string;
183
+ /** The producer's HTTP status, when there was one. */
184
+ readonly status?: number;
185
+ };
186
+ /**
187
+ * A taken body.
188
+ *
189
+ * `stream` is handed over **paused**, and that is load-bearing rather than
190
+ * tidy. Node flushes the body bytes it already holds on `process.nextTick` the
191
+ * moment a `data` listener attaches, and that tick runs BEFORE any promise
192
+ * continuation the consumer was going to use to get ready — which is exactly
193
+ * how the broker's first dial threw away the head of every stream (`ftyp`,
194
+ * `moov`) while fourteen unit tests stayed green, because the fake opener only
195
+ * ever delivered after the `await` (D597). Here the facility pauses the
196
+ * response before it resolves, so a consumer that takes a tick to get ready
197
+ * cannot lose the head.
198
+ */
199
+ export interface PeerBytesBody {
200
+ readonly status: number;
201
+ readonly contentType: string | null;
202
+ /** The producer's `content-length`, or `null` when it declared none (D393). */
203
+ readonly declaredBytes: number | null;
204
+ /** PAUSED. Nothing flows until the consumer reads it. */
205
+ readonly stream: Readable;
206
+ /**
207
+ * Drain the whole body into `path`, honouring backpressure, under a bound.
208
+ *
209
+ * Over the bound the transfer is ABORTED and the partial file removed — half
210
+ * a video is worse than an honest refusal, and this repo has shipped a
211
+ * 262-byte MP4 to a phone once already.
212
+ */
213
+ writeToFile(path: string, options: PeerBytesWriteOptions): Promise<PeerBytesWriteResult>;
214
+ /** Give up on the body. Idempotent; safe after it has ended. */
215
+ cancel(reason: string): void;
216
+ }
217
+ export interface PeerBytesWriteOptions {
218
+ /** Hard ceiling. A body that exceeds it is aborted, never truncated. */
219
+ readonly maxBytes: number;
220
+ }
221
+ export type PeerBytesWriteResult = {
222
+ readonly kind: 'written';
223
+ readonly bytes: number;
224
+ } | {
225
+ readonly kind: 'refused';
226
+ readonly code: 'too-large' | 'transfer-failed' | 'write-failed';
227
+ readonly detail: string;
228
+ /** How much had arrived when it stopped. */
229
+ readonly bytes: number;
230
+ };
231
+ /**
232
+ * The facility. Injected by the kernel; absent when the addon is loaded in
233
+ * isolation (tests, tooling), which is why `AddonContext.peerBytes` is optional.
234
+ */
235
+ export interface AddonPeerBytes {
236
+ /** This process's HOST node — the hub or a named agent, never a runner id. */
237
+ readonly hostNodeId: string;
238
+ /** Tickets minted and not yet taken or expired. */
239
+ readonly outstanding: number;
240
+ offer(options: PeerBytesOfferOptions): Promise<PeerBytesOfferResult>;
241
+ offerBuffer(options: PeerBytesBufferOffer): Promise<PeerBytesOfferResult>;
242
+ offerFile(options: PeerBytesFileOffer): Promise<PeerBytesOfferResult>;
243
+ open(ticket: PeerBytesTicket): Promise<PeerBytesOpenResult>;
244
+ }
@@ -3,6 +3,7 @@ import type { CapabilityDefinition, InferProvider } from '../capabilities/capabi
3
3
  import type { CustomActionCaller, CustomActionsSpec } from '../capabilities/custom-actions.js';
4
4
  import type { PipelineSlot } from '../types/pipeline.js';
5
5
  import type { AddonDataPlane } from './addon-data-plane.js';
6
+ import type { AddonPeerBytes } from './addon-peer-bytes.js';
6
7
  import type { CapabilityDeclaration } from './capability.js';
7
8
  import type { ConfigUISchemaWithValues, FieldProbeResult } from './config-ui.js';
8
9
  import type { IEventBus, ReadinessScope } from './event-bus.js';
@@ -656,6 +657,24 @@ export interface AddonContext<TConfig = Record<string, unknown>> {
656
657
  * tooling), which is why the field is optional.
657
658
  */
658
659
  readonly dataPlane?: AddonDataPlane;
660
+ /**
661
+ * Addon→addon **byte transport** (D613) — how a consumer addon gets another
662
+ * addon's bytes without either of them holding the payload whole.
663
+ *
664
+ * `dataPlane` lets an addon serve the HUB; this lets it serve a SIBLING. The
665
+ * producer offers one body and gets a one-shot ticket it hands back through
666
+ * the cap method it already gates; the consumer opens the ticket and streams.
667
+ * Same host only — a ticket from another node is refused `cross-node` by
668
+ * name, never dialled. See {@link AddonPeerBytes}.
669
+ *
670
+ * Use it instead of a base64 field on a unary cap method. That shape holds
671
+ * the payload whole (~1.33×) in the producer AND in the caller and is why
672
+ * every such method has had to carry a byte bound (D9/D18).
673
+ *
674
+ * Injected by the kernel; absent when the addon is loaded in isolation
675
+ * (tests, tooling), which is why the field is optional.
676
+ */
677
+ readonly peerBytes?: AddonPeerBytes;
659
678
  /**
660
679
  * Runtime accessor for the capability registry — exposed to addons that
661
680
  * need to enumerate peers of a collection cap (e.g. `turn-provider`) or
@@ -3912,7 +3912,9 @@ function createDeviceProxy(api, binding, opts) {
3912
3912
  listSources: (input) => dispatch("videoclips", "videoclips", "listSources", "query", input),
3913
3913
  getClipPlayback: (input) => dispatch("videoclips", "videoclips", "getClipPlayback", "query", input),
3914
3914
  readClipBytes: (input) => dispatch("videoclips", "videoclips", "readClipBytes", "query", input),
3915
- dialClipStream: (input) => dispatch("videoclips", "videoclips", "dialClipStream", "query", input)
3915
+ offerClipBytes: (input) => dispatch("videoclips", "videoclips", "offerClipBytes", "query", input),
3916
+ dialClipStream: (input) => dispatch("videoclips", "videoclips", "dialClipStream", "query", input),
3917
+ getPlaybackOptions: (input) => dispatch("videoclips", "videoclips", "getPlaybackOptions", "query", input)
3916
3918
  },
3917
3919
  waterHeater: {
3918
3920
  setTargetTemp: (input) => dispatch("water-heater", "waterHeater", "setTargetTemp", "mutation", input),
@@ -3912,7 +3912,9 @@ function createDeviceProxy(api, binding, opts) {
3912
3912
  listSources: (input) => dispatch("videoclips", "videoclips", "listSources", "query", input),
3913
3913
  getClipPlayback: (input) => dispatch("videoclips", "videoclips", "getClipPlayback", "query", input),
3914
3914
  readClipBytes: (input) => dispatch("videoclips", "videoclips", "readClipBytes", "query", input),
3915
- dialClipStream: (input) => dispatch("videoclips", "videoclips", "dialClipStream", "query", input)
3915
+ offerClipBytes: (input) => dispatch("videoclips", "videoclips", "offerClipBytes", "query", input),
3916
+ dialClipStream: (input) => dispatch("videoclips", "videoclips", "dialClipStream", "query", input),
3917
+ getPlaybackOptions: (input) => dispatch("videoclips", "videoclips", "getPlaybackOptions", "query", input)
3916
3918
  },
3917
3919
  waterHeater: {
3918
3920
  setTargetTemp: (input) => dispatch("water-heater", "waterHeater", "setTargetTemp", "mutation", input),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/types",
3
- "version": "1.2.245",
3
+ "version": "1.2.246",
4
4
  "description": "Shared types, interfaces, and model catalogs for the CamStack detection ecosystem",
5
5
  "keywords": [
6
6
  "camstack",