@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.
- package/dist/addon.js +1 -1
- package/dist/addon.mjs +1 -1
- package/dist/capabilities/index.d.ts +2 -2
- package/dist/capabilities/videoclips.cap.d.ts +301 -11
- package/dist/generated/addon-api.d.ts +14 -0
- package/dist/generated/method-access-map.d.ts +1 -1
- package/dist/generated/method-device-selectors.d.ts +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +387 -12
- package/dist/index.mjs +377 -13
- package/dist/interfaces/addon-peer-bytes.d.ts +244 -0
- package/dist/interfaces/addon.d.ts +19 -0
- package/dist/{sleep-i3eUVc-d.mjs → sleep-COWaSCAi.mjs} +3 -1
- package/dist/{sleep-Da9UCU5_.js → sleep-DWUJM120.js} +3 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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),
|