@yourrealm/sdk 0.0.0-alpha.1378.ce03c8dc

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 (230) hide show
  1. package/LICENSE-EXCEPTION +26 -0
  2. package/README.md +34 -0
  3. package/dist/chunk-3R6KEFOI.js +22 -0
  4. package/dist/chunk-67BDAMYX.js +376 -0
  5. package/dist/chunk-AITGS27R.js +139 -0
  6. package/dist/chunk-CMAEHQ4F.js +6 -0
  7. package/dist/chunk-MLKGABMK.js +9 -0
  8. package/dist/chunk-O3UIK34B.js +1430 -0
  9. package/dist/chunk-VHTUSZUX.js +176 -0
  10. package/dist/cli.js +9006 -0
  11. package/dist/docker.js +54 -0
  12. package/dist/index.js +1053 -0
  13. package/dist/ingress.js +436 -0
  14. package/dist/jsx-runtime.js +2 -0
  15. package/dist/preact.js +22 -0
  16. package/dist/service-url.js +11 -0
  17. package/dist/services.js +213 -0
  18. package/dist/test.js +5069 -0
  19. package/dist/ui.js +254 -0
  20. package/dist/worker-protocol.js +89 -0
  21. package/package.json +112 -0
  22. package/src/app-icon.ts +74 -0
  23. package/src/apply.ts +52 -0
  24. package/src/bundle-ui.ts +211 -0
  25. package/src/bundle.ts +546 -0
  26. package/src/cli/build-dev-landing.ts +84 -0
  27. package/src/cli/build-install-panel.ts +90 -0
  28. package/src/cli/build-runtime.ts +133 -0
  29. package/src/cli/build.ts +63 -0
  30. package/src/cli/dev.ts +107 -0
  31. package/src/cli/down.ts +60 -0
  32. package/src/cli/main.ts +56 -0
  33. package/src/cli/test.ts +61 -0
  34. package/src/cli/validate.ts +37 -0
  35. package/src/compile.ts +100 -0
  36. package/src/create-app.ts +982 -0
  37. package/src/deep-merge.ts +33 -0
  38. package/src/dev/fixture.ts +48 -0
  39. package/src/dev/install-handler.ts +383 -0
  40. package/src/dev/server.ts +968 -0
  41. package/src/dev/stack.ts +108 -0
  42. package/src/dev/state.ts +159 -0
  43. package/src/docker.ts +96 -0
  44. package/src/errors.ts +61 -0
  45. package/src/factory.ts +481 -0
  46. package/src/garage.ts +56 -0
  47. package/src/generator/hash.ts +6 -0
  48. package/src/generator/resolve-refs.ts +207 -0
  49. package/src/grants-builder.ts +100 -0
  50. package/src/grants.ts +260 -0
  51. package/src/home-api.ts +172 -0
  52. package/src/home-cookie.ts +10 -0
  53. package/src/home-registry.ts +120 -0
  54. package/src/home.ts +187 -0
  55. package/src/host-registry.ts +143 -0
  56. package/src/http-header.ts +20 -0
  57. package/src/identifiers.ts +49 -0
  58. package/src/index.ts +66 -0
  59. package/src/info.ts +64 -0
  60. package/src/ini.ts +50 -0
  61. package/src/json.ts +14 -0
  62. package/src/jsx-runtime/index.ts +5 -0
  63. package/src/keys.ts +29 -0
  64. package/src/mysql.ts +56 -0
  65. package/src/pack-build.ts +108 -0
  66. package/src/pack.ts +115 -0
  67. package/src/peer-registry.ts +121 -0
  68. package/src/peer-world.ts +117 -0
  69. package/src/permission-meta.ts +116 -0
  70. package/src/permissions.ts +108 -0
  71. package/src/pkg-root.ts +34 -0
  72. package/src/postgres.ts +79 -0
  73. package/src/preact/index.ts +27 -0
  74. package/src/redis.ts +43 -0
  75. package/src/ref.ts +271 -0
  76. package/src/remote-registry.ts +33 -0
  77. package/src/required-host-kinds.ts +110 -0
  78. package/src/resolve-context.ts +111 -0
  79. package/src/resolve.ts +471 -0
  80. package/src/runtime/docker/compose/compose-schema.ts +118 -0
  81. package/src/runtime/docker/compose/convert-to-docker-compose.ts +278 -0
  82. package/src/runtime/docker/compose/default-hardening.ts +21 -0
  83. package/src/runtime/docker/compose/effects.ts +107 -0
  84. package/src/runtime/docker/compose/index.ts +17 -0
  85. package/src/runtime/docker/compose/labels.ts +39 -0
  86. package/src/runtime/docker/compose/types.ts +78 -0
  87. package/src/runtime/docker/home-proxy-server/index.ts +177 -0
  88. package/src/runtime/docker/home-proxy-server/protocol.ts +53 -0
  89. package/src/runtime/docker/ingress/build-config.ts +819 -0
  90. package/src/runtime/docker/ingress/index.ts +16 -0
  91. package/src/runtime/docker/ingress/types.ts +182 -0
  92. package/src/runtime/docker/log-frames.ts +121 -0
  93. package/src/runtime/docker/logs-server/index.ts +366 -0
  94. package/src/runtime/docker/spawn-server/context-key.ts +82 -0
  95. package/src/runtime/docker/spawn-server/index.ts +492 -0
  96. package/src/runtime/docker/spawn-server/protocol.ts +70 -0
  97. package/src/runtime/docker/spawn-server/spec.ts +122 -0
  98. package/src/runtime/docker/stats-server/disks.ts +161 -0
  99. package/src/runtime/docker/stats-server/index.ts +322 -0
  100. package/src/runtime/home-client/index.ts +71 -0
  101. package/src/runtime/log-line.ts +24 -0
  102. package/src/runtime/logs-client/index.ts +175 -0
  103. package/src/runtime/spawn-client/index.ts +93 -0
  104. package/src/runtime/spawn-client/kaniko.ts +79 -0
  105. package/src/runtime/stats-client/index.ts +65 -0
  106. package/src/runtime/stats-sample.ts +95 -0
  107. package/src/safe-json.ts +37 -0
  108. package/src/sandbox.ts +1209 -0
  109. package/src/schema/build-apps.ts +38 -0
  110. package/src/schema/home.ts +217 -0
  111. package/src/schema/host.ts +792 -0
  112. package/src/schema/inputs.ts +26 -0
  113. package/src/schema/nodes.ts +75 -0
  114. package/src/schema/peer.ts +400 -0
  115. package/src/schema/public-domain.ts +116 -0
  116. package/src/schema/remote.ts +74 -0
  117. package/src/security.ts +594 -0
  118. package/src/serialize.ts +21 -0
  119. package/src/service-definition.ts +284 -0
  120. package/src/service-url.ts +64 -0
  121. package/src/service.ts +39 -0
  122. package/src/services/index.ts +5 -0
  123. package/src/shared.ts +65 -0
  124. package/src/speculative-silo.ts +96 -0
  125. package/src/stable-secret.ts +14 -0
  126. package/src/surfaces.ts +264 -0
  127. package/src/tailwind-bin.ts +56 -0
  128. package/src/test/boot.ts +192 -0
  129. package/src/test/compile.ts +501 -0
  130. package/src/test/describe.ts +117 -0
  131. package/src/test/index.ts +11 -0
  132. package/src/test/ingress-fixture.ts +37 -0
  133. package/src/test/types.ts +318 -0
  134. package/src/trusted-headers.ts +79 -0
  135. package/src/types.ts +126 -0
  136. package/src/ui/components/aside.tsx +96 -0
  137. package/src/ui/components/badge.tsx +42 -0
  138. package/src/ui/components/button.tsx +59 -0
  139. package/src/ui/components/card.tsx +106 -0
  140. package/src/ui/components/checkbox.tsx +37 -0
  141. package/src/ui/components/code-block.tsx +29 -0
  142. package/src/ui/components/combobox.tsx +119 -0
  143. package/src/ui/components/config-field.tsx +284 -0
  144. package/src/ui/components/dialog.tsx +137 -0
  145. package/src/ui/components/domain-editor.tsx +317 -0
  146. package/src/ui/components/domain-list.ts +205 -0
  147. package/src/ui/components/dropdown-menu.tsx +73 -0
  148. package/src/ui/components/empty-state.tsx +51 -0
  149. package/src/ui/components/highlight.ts +59 -0
  150. package/src/ui/components/icons.tsx +101 -0
  151. package/src/ui/components/image-posture.tsx +85 -0
  152. package/src/ui/components/input.tsx +28 -0
  153. package/src/ui/components/label.tsx +16 -0
  154. package/src/ui/components/line-chart.tsx +113 -0
  155. package/src/ui/components/page.tsx +39 -0
  156. package/src/ui/components/permission-row.tsx +262 -0
  157. package/src/ui/components/permission-section.tsx +51 -0
  158. package/src/ui/components/pie-chart.tsx +245 -0
  159. package/src/ui/components/schema-block.tsx +60 -0
  160. package/src/ui/components/separator.tsx +42 -0
  161. package/src/ui/components/sheet.tsx +130 -0
  162. package/src/ui/components/sidebar.tsx +650 -0
  163. package/src/ui/components/silo-picker.tsx +510 -0
  164. package/src/ui/components/skeleton.tsx +13 -0
  165. package/src/ui/components/sonner.tsx +33 -0
  166. package/src/ui/components/switch.tsx +58 -0
  167. package/src/ui/components/table-view.ts +219 -0
  168. package/src/ui/components/table.tsx +506 -0
  169. package/src/ui/components/tabs.tsx +59 -0
  170. package/src/ui/components/timeago.tsx +84 -0
  171. package/src/ui/components/tooltip.tsx +56 -0
  172. package/src/ui/components.json +21 -0
  173. package/src/ui/dev/dev-shell.tsx +145 -0
  174. package/src/ui/home/home-grid.tsx +177 -0
  175. package/src/ui/hooks/use-mobile.ts +19 -0
  176. package/src/ui/index.ts +189 -0
  177. package/src/ui/install/adapter.ts +193 -0
  178. package/src/ui/install/deploy-targets-picker.tsx +185 -0
  179. package/src/ui/install/install-app-panel.tsx +912 -0
  180. package/src/ui/install/install-error.tsx +62 -0
  181. package/src/ui/install/perms-inactive.ts +50 -0
  182. package/src/ui/install/required-fields.ts +37 -0
  183. package/src/ui/install/tabs-form.tsx +557 -0
  184. package/src/ui/tokens.css +279 -0
  185. package/src/ui/utils.ts +78 -0
  186. package/src/ui-error.ts +139 -0
  187. package/src/ui-runtime/dev-landing-entry.tsx +258 -0
  188. package/src/ui-runtime/index.ts +83 -0
  189. package/src/ui-runtime/install-entry.tsx +119 -0
  190. package/src/ui-runtime/mount.ts +367 -0
  191. package/src/ui-runtime/runtime.css +56 -0
  192. package/src/ui-surface.ts +451 -0
  193. package/src/valkey.ts +43 -0
  194. package/src/worker/extract-tar.ts +125 -0
  195. package/src/worker/identity.ts +183 -0
  196. package/src/worker/index.ts +16 -0
  197. package/src/worker/public-ip.ts +92 -0
  198. package/src/worker/queue.ts +54 -0
  199. package/src/worker/reconcile.ts +127 -0
  200. package/src/worker/start-worker.ts +227 -0
  201. package/src/worker/ui-proxy.ts +335 -0
  202. package/src/worker/uplink.ts +523 -0
  203. package/src/worker/validate.ts +34 -0
  204. package/src/worker/verify-relay.ts +102 -0
  205. package/src/worker-protocol/app-dirs.ts +94 -0
  206. package/src/worker-protocol/home-proxy-paths.ts +76 -0
  207. package/src/worker-protocol/index.ts +55 -0
  208. package/src/worker-protocol/ingress.ts +166 -0
  209. package/src/worker-protocol/logs-socket-path.ts +50 -0
  210. package/src/worker-protocol/networks.ts +39 -0
  211. package/src/worker-protocol/node-version.ts +70 -0
  212. package/src/worker-protocol/silo-paths.ts +114 -0
  213. package/src/worker-protocol/spawn-paths.ts +165 -0
  214. package/src/worker-protocol/stats-socket-path.ts +40 -0
  215. package/src/worker-protocol/update-files.ts +77 -0
  216. package/src/worker-protocol/uplink/constants.ts +86 -0
  217. package/src/worker-protocol/uplink/errors.ts +61 -0
  218. package/src/worker-protocol/uplink/frame.ts +114 -0
  219. package/src/worker-protocol/uplink/identity.ts +40 -0
  220. package/src/worker-protocol/uplink/index.ts +63 -0
  221. package/src/worker-protocol/uplink/methods.ts +462 -0
  222. package/src/worker-protocol/uplink/mux.ts +646 -0
  223. package/src/worker-protocol/uplink/protocol.ts +11 -0
  224. package/src/worker-protocol/uplink/rpc.ts +309 -0
  225. package/src/worker-protocol/uplink/session.ts +540 -0
  226. package/src/worker-protocol/uplink/test-support.ts +188 -0
  227. package/src/worker-protocol/worker-mount.ts +27 -0
  228. package/src/worker-protocol/worker-state.ts +190 -0
  229. package/src/xml.ts +49 -0
  230. package/src/yaml.ts +15 -0
@@ -0,0 +1,646 @@
1
+ // Many independent streams over one ordered, reliable byte stream. In
2
+ // production that byte stream is the plaintext side of a Noise transport;
3
+ // here it is anything shaped like `ByteDuplex`.
4
+ //
5
+ // The mux owns framing, per-stream receive windows, the session caps and
6
+ // PING/PONG. It owns no schedule: ping cadence, pong deadline and idle
7
+ // deadline all belong to the session layer above it.
8
+
9
+ import {
10
+ CONTROL_STREAM_ID,
11
+ DEFAULT_LIMITS,
12
+ MAX_PAYLOAD_BYTES,
13
+ MAX_RESET_REASON_CHARS,
14
+ PING_PAYLOAD_BYTES,
15
+ WINDOW_PAYLOAD_BYTES,
16
+ } from "./constants.ts";
17
+ import { SessionClosed, StreamReset, UplinkProtocolError } from "./errors.ts";
18
+ import {
19
+ decodeU32,
20
+ decodeUtf8,
21
+ EMPTY_PAYLOAD,
22
+ encodeFrame,
23
+ encodeU32,
24
+ encodeUtf8,
25
+ type Frame,
26
+ FrameParser,
27
+ FrameType,
28
+ frameTypeName,
29
+ } from "./frame.ts";
30
+
31
+ /** The abstract transport: ordered and reliable in each direction. */
32
+ export interface ByteDuplex {
33
+ readable: ReadableStream<Uint8Array>;
34
+ writable: WritableStream<Uint8Array>;
35
+ }
36
+
37
+ export interface Limits {
38
+ /** Largest DATA payload this side emits. */
39
+ fragmentSize: number;
40
+ /** Per-stream receive window, each direction. */
41
+ initialWindow: number;
42
+ /** Concurrent streams this side may have open. */
43
+ maxStreams: number;
44
+ /** Un-credited DATA bytes in flight across all of this side's sends. */
45
+ maxInFlight: number;
46
+ }
47
+
48
+ export type MuxSide = "initiator" | "responder";
49
+
50
+ export interface Stream {
51
+ readonly id: number;
52
+ /** The peer's DATA. Closes on END, errors with `StreamReset` on RESET. */
53
+ readonly readable: ReadableStream<Uint8Array>;
54
+ /** Our DATA. `close()` sends END, `abort(reason)` sends RESET. */
55
+ readonly writable: WritableStream<Uint8Array>;
56
+ reset(reason: string): void;
57
+ }
58
+
59
+ interface SendEntry {
60
+ bytes: Uint8Array;
61
+ streamId: number;
62
+ resolve: () => void;
63
+ reject: (error: Error) => void;
64
+ }
65
+
66
+ interface StreamState {
67
+ id: number;
68
+ /** True when this side opened it, which is also what the id's parity says. */
69
+ local: boolean;
70
+ stream: Stream;
71
+ retired: boolean;
72
+ error: Error | undefined;
73
+ // Send side.
74
+ sendCredit: number;
75
+ sendOutstanding: number;
76
+ writeEnded: boolean;
77
+ writeController: WritableStreamDefaultController | undefined;
78
+ // Receive side.
79
+ recvRemaining: number;
80
+ recvUncredited: number;
81
+ consumed: number;
82
+ recvQueue: Uint8Array[];
83
+ recvEnded: boolean;
84
+ readSettled: boolean;
85
+ readController: ReadableStreamDefaultController<Uint8Array> | undefined;
86
+ pullResolve: (() => void) | undefined;
87
+ }
88
+
89
+ /** One-shot notification a waiter re-checks its own condition against. */
90
+ class Gate {
91
+ #promise: Promise<void> | undefined;
92
+ #resolve: (() => void) | undefined;
93
+
94
+ wait(): Promise<void> {
95
+ this.#promise ??= new Promise<void>((resolve) => {
96
+ this.#resolve = resolve;
97
+ });
98
+ return this.#promise;
99
+ }
100
+
101
+ wake(): void {
102
+ const resolve = this.#resolve;
103
+ this.#promise = undefined;
104
+ this.#resolve = undefined;
105
+ resolve?.();
106
+ }
107
+ }
108
+
109
+ export class Mux {
110
+ readonly #limits: Limits;
111
+ readonly #grantThreshold: number;
112
+ readonly #localOdd: boolean;
113
+ readonly #writer: WritableStreamDefaultWriter<Uint8Array>;
114
+ readonly #reader: ReadableStreamDefaultReader<Uint8Array>;
115
+ readonly #parser = new FrameParser();
116
+ readonly #streams = new Map<number, StreamState>();
117
+ readonly #controlQueue: SendEntry[] = [];
118
+ readonly #dataQueue: SendEntry[] = [];
119
+ readonly #creditGate = new Gate();
120
+ readonly #slotGate = new Gate();
121
+ readonly #pings = new Map<number, { resolve: () => void; reject: (error: Error) => void }>();
122
+ readonly #closedPromise: Promise<string>;
123
+ readonly #closedResolve: (reason: string) => void;
124
+
125
+ #onOpen: ((stream: Stream, header: Uint8Array) => void) | undefined;
126
+ #pendingOpens: Array<{ stream: Stream; header: Uint8Array }> = [];
127
+ #nextId: number;
128
+ #localMaxId = 0;
129
+ #peerMaxId = 0;
130
+ #localOpen = 0;
131
+ #remoteOpen = 0;
132
+ #inFlight = 0;
133
+ #peerInFlight = 0;
134
+ #pingSeq = 0;
135
+ #pumping = false;
136
+ #closeError: Error | undefined;
137
+
138
+ constructor(transport: ByteDuplex, opts: { side: MuxSide } & Partial<Limits>) {
139
+ const limits: Limits = {
140
+ fragmentSize: opts.fragmentSize ?? DEFAULT_LIMITS.fragmentSize,
141
+ initialWindow: opts.initialWindow ?? DEFAULT_LIMITS.initialWindow,
142
+ maxStreams: opts.maxStreams ?? DEFAULT_LIMITS.maxStreams,
143
+ maxInFlight: opts.maxInFlight ?? DEFAULT_LIMITS.maxInFlight,
144
+ };
145
+ // A fragment is sent atomically, so anything it cannot fit inside would
146
+ // stall forever rather than fail.
147
+ if (limits.fragmentSize < 1 || limits.fragmentSize > MAX_PAYLOAD_BYTES) {
148
+ throw new RangeError(`fragmentSize must be 1..${MAX_PAYLOAD_BYTES}`);
149
+ }
150
+ if (limits.initialWindow < limits.fragmentSize) {
151
+ throw new RangeError("initialWindow must be at least fragmentSize");
152
+ }
153
+ if (limits.maxInFlight < limits.fragmentSize) {
154
+ throw new RangeError("maxInFlight must be at least fragmentSize");
155
+ }
156
+ if (limits.maxStreams < 1) throw new RangeError("maxStreams must be at least 1");
157
+
158
+ this.#limits = limits;
159
+ this.#grantThreshold = Math.max(1, Math.floor(limits.initialWindow / 2));
160
+ this.#localOdd = opts.side === "initiator";
161
+ this.#nextId = this.#localOdd ? 1 : 2;
162
+ this.#writer = transport.writable.getWriter();
163
+ this.#reader = transport.readable.getReader();
164
+
165
+ let resolveClosed: (reason: string) => void = () => {};
166
+ this.#closedPromise = new Promise<string>((resolve) => {
167
+ resolveClosed = resolve;
168
+ });
169
+ this.#closedResolve = resolveClosed;
170
+
171
+ void this.#readLoop();
172
+ }
173
+
174
+ /** The close reason, whichever side closed. Never rejects. */
175
+ get closed(): Promise<string> {
176
+ return this.#closedPromise;
177
+ }
178
+
179
+ /**
180
+ * Opens a stream. Costs no round trip — the first DATA may follow at once.
181
+ * Waits only for a local slot when `maxStreams` are already open.
182
+ */
183
+ async open(header: Uint8Array): Promise<Stream> {
184
+ if (header.length > MAX_PAYLOAD_BYTES) {
185
+ throw new RangeError(`OPEN header ${header.length} exceeds ${MAX_PAYLOAD_BYTES} bytes`);
186
+ }
187
+ while (this.#closeError === undefined && this.#localOpen >= this.#limits.maxStreams) {
188
+ await this.#slotGate.wait();
189
+ }
190
+ if (this.#closeError !== undefined) throw this.#closeError;
191
+ if (this.#nextId > 0xffffffff) throw new RangeError("stream id space exhausted");
192
+ const id = this.#nextId;
193
+ this.#nextId += 2;
194
+ this.#localMaxId = id;
195
+ const state = this.#createStream(id, true);
196
+ this.#localOpen++;
197
+ this.#sendControl(encodeFrame(FrameType.OPEN, id, header), id);
198
+ return state.stream;
199
+ }
200
+
201
+ /** Registers the incoming-stream handler. Opens seen before this are replayed. */
202
+ onOpen(handler: (stream: Stream, header: Uint8Array) => void): void {
203
+ this.#onOpen = handler;
204
+ const pending = this.#pendingOpens;
205
+ this.#pendingOpens = [];
206
+ for (const entry of pending) handler(entry.stream, entry.header);
207
+ }
208
+
209
+ /** Resolves on the matching PONG. The caller owns the deadline. */
210
+ ping(): Promise<void> {
211
+ if (this.#closeError !== undefined) return Promise.reject(this.#closeError);
212
+ const seq = ++this.#pingSeq;
213
+ const payload = new Uint8Array(PING_PAYLOAD_BYTES);
214
+ new DataView(payload.buffer).setUint32(4, seq);
215
+ const waited = new Promise<void>((resolve, reject) => {
216
+ this.#pings.set(seq, { resolve, reject });
217
+ });
218
+ this.#sendControl(encodeFrame(FrameType.PING, CONTROL_STREAM_ID, payload));
219
+ return waited;
220
+ }
221
+
222
+ /** Local close. Idempotent. */
223
+ close(reason: string): void {
224
+ this.#shutdown(new SessionClosed(reason), reason);
225
+ }
226
+
227
+ // --- send path -------------------------------------------------------
228
+
229
+ // Control outranks DATA so a kick never queues behind a body. END cannot
230
+ // overtake its own stream's DATA even so: the writable's `close` runs only
231
+ // after every `write` has resolved, which is after its frames are written.
232
+ #sendControl(bytes: Uint8Array, streamId = CONTROL_STREAM_ID): void {
233
+ if (this.#closeError !== undefined) return;
234
+ this.#controlQueue.push({ bytes, streamId, resolve: noop, reject: noop });
235
+ void this.#pump();
236
+ }
237
+
238
+ #sendData(streamId: number, bytes: Uint8Array): Promise<void> {
239
+ return new Promise<void>((resolve, reject) => {
240
+ this.#dataQueue.push({ bytes, streamId, resolve, reject });
241
+ void this.#pump();
242
+ });
243
+ }
244
+
245
+ // One writer on the transport, awaited, so the sink's backpressure
246
+ // reaches the stream that produced the bytes.
247
+ async #pump(): Promise<void> {
248
+ if (this.#pumping) return;
249
+ this.#pumping = true;
250
+ try {
251
+ for (;;) {
252
+ const entry = this.#controlQueue.shift() ?? this.#dataQueue.shift();
253
+ if (entry === undefined) return;
254
+ try {
255
+ await this.#writer.write(entry.bytes);
256
+ } catch (e) {
257
+ const error = asError(e);
258
+ entry.reject(error);
259
+ this.#shutdown(new SessionClosed(error.message), error.message);
260
+ return;
261
+ }
262
+ entry.resolve();
263
+ }
264
+ } finally {
265
+ this.#pumping = false;
266
+ }
267
+ }
268
+
269
+ async #write(state: StreamState, chunk: Uint8Array): Promise<void> {
270
+ const size = this.#limits.fragmentSize;
271
+ for (let offset = 0; offset < chunk.length; offset += size) {
272
+ const part = chunk.subarray(offset, Math.min(offset + size, chunk.length));
273
+ await this.#reserve(state, part.length);
274
+ await this.#sendData(state.id, encodeFrame(FrameType.DATA, state.id, part));
275
+ }
276
+ }
277
+
278
+ // Stalls until the stream window and the session in-flight cap both have
279
+ // room for one whole fragment.
280
+ async #reserve(state: StreamState, size: number): Promise<void> {
281
+ for (;;) {
282
+ if (this.#closeError !== undefined) throw this.#closeError;
283
+ if (state.retired) throw state.error ?? new StreamReset("stream closed");
284
+ const sessionRoom = this.#limits.maxInFlight - this.#inFlight;
285
+ if (state.sendCredit >= size && sessionRoom >= size) {
286
+ state.sendCredit -= size;
287
+ state.sendOutstanding += size;
288
+ this.#inFlight += size;
289
+ return;
290
+ }
291
+ await this.#creditGate.wait();
292
+ }
293
+ }
294
+
295
+ #endWrite(state: StreamState): void {
296
+ if (state.writeEnded || state.retired) return;
297
+ state.writeEnded = true;
298
+ this.#sendControl(encodeFrame(FrameType.END, state.id, EMPTY_PAYLOAD), state.id);
299
+ this.#maybeRetire(state);
300
+ }
301
+
302
+ #resetStream(state: StreamState, reason: string): void {
303
+ if (state.retired) return;
304
+ const text = reason.slice(0, MAX_RESET_REASON_CHARS);
305
+ this.#sendControl(encodeFrame(FrameType.RESET, state.id, encodeUtf8(text)), state.id);
306
+ this.#failStream(state, new StreamReset(text));
307
+ }
308
+
309
+ // --- receive path ----------------------------------------------------
310
+
311
+ async #readLoop(): Promise<void> {
312
+ try {
313
+ for (;;) {
314
+ const { value, done } = await this.#reader.read();
315
+ if (done) {
316
+ this.#shutdown(new SessionClosed("transport closed"), "transport closed");
317
+ return;
318
+ }
319
+ for (const frame of this.#parser.push(value)) this.#handle(frame);
320
+ }
321
+ } catch (e) {
322
+ if (e instanceof UplinkProtocolError) this.#shutdown(e, e.message);
323
+ else {
324
+ const error = asError(e);
325
+ this.#shutdown(new SessionClosed(error.message), error.message);
326
+ }
327
+ }
328
+ }
329
+
330
+ #handle(frame: Frame): void {
331
+ switch (frame.type) {
332
+ case FrameType.PING:
333
+ case FrameType.PONG: {
334
+ if (frame.streamId !== CONTROL_STREAM_ID) {
335
+ throw new UplinkProtocolError(`${frameTypeName(frame.type)} on stream ${frame.streamId}`);
336
+ }
337
+ if (frame.payload.length !== PING_PAYLOAD_BYTES) {
338
+ throw new UplinkProtocolError(
339
+ `${frameTypeName(frame.type)} payload must be ${PING_PAYLOAD_BYTES} bytes`,
340
+ );
341
+ }
342
+ if (frame.type === FrameType.PING) {
343
+ this.#sendControl(encodeFrame(FrameType.PONG, CONTROL_STREAM_ID, frame.payload));
344
+ return;
345
+ }
346
+ const view = new DataView(
347
+ frame.payload.buffer,
348
+ frame.payload.byteOffset,
349
+ frame.payload.byteLength,
350
+ );
351
+ const seq = view.getUint32(4);
352
+ const waiter = this.#pings.get(seq);
353
+ // An unsolicited PONG is noise, not a violation.
354
+ if (waiter !== undefined) {
355
+ this.#pings.delete(seq);
356
+ waiter.resolve();
357
+ }
358
+ return;
359
+ }
360
+ case FrameType.OPEN:
361
+ return this.#handleOpen(frame);
362
+ default:
363
+ return this.#handleStreamFrame(frame);
364
+ }
365
+ }
366
+
367
+ #handleOpen(frame: Frame): void {
368
+ const id = frame.streamId;
369
+ if (id === CONTROL_STREAM_ID || (id % 2 === 1) === this.#localOdd) {
370
+ throw new UplinkProtocolError(`OPEN on wrong-parity stream ${id}`);
371
+ }
372
+ // Ids only grow, which is what lets late frames on a torn-down stream be
373
+ // told apart from frames on a stream that was never opened.
374
+ if (id <= this.#peerMaxId) throw new UplinkProtocolError(`OPEN reuses stream ${id}`);
375
+ this.#peerMaxId = id;
376
+ if (this.#remoteOpen >= this.#limits.maxStreams) {
377
+ // The peer's own bookkeeping is broken, not the framing: refuse the
378
+ // one stream rather than take the whole session down.
379
+ this.#sendControl(encodeFrame(FrameType.RESET, id, encodeUtf8("too many streams")), id);
380
+ return;
381
+ }
382
+ const state = this.#createStream(id, false);
383
+ this.#remoteOpen++;
384
+ const handler = this.#onOpen;
385
+ if (handler === undefined) {
386
+ this.#pendingOpens.push({ stream: state.stream, header: frame.payload });
387
+ return;
388
+ }
389
+ try {
390
+ handler(state.stream, frame.payload);
391
+ } catch (e) {
392
+ this.#resetStream(state, asError(e).message);
393
+ }
394
+ }
395
+
396
+ #handleStreamFrame(frame: Frame): void {
397
+ const id = frame.streamId;
398
+ const name = frameTypeName(frame.type);
399
+ if (id === CONTROL_STREAM_ID) throw new UplinkProtocolError(`${name} on stream 0`);
400
+ const state = this.#streams.get(id);
401
+ if (state === undefined) {
402
+ const mine = (id % 2 === 1) === this.#localOdd;
403
+ const everOpened = mine ? id <= this.#localMaxId : id <= this.#peerMaxId;
404
+ if (!everOpened) throw new UplinkProtocolError(`${name} on unopened stream ${id}`);
405
+ return; // Torn down here; the peer had frames in flight.
406
+ }
407
+ switch (frame.type) {
408
+ case FrameType.DATA:
409
+ return this.#handleData(state, frame.payload);
410
+ case FrameType.END: {
411
+ if (frame.payload.length !== 0) throw new UplinkProtocolError("END carries a payload");
412
+ if (state.recvEnded) throw new UplinkProtocolError(`duplicate END on stream ${id}`);
413
+ state.recvEnded = true;
414
+ const pull = state.pullResolve;
415
+ if (pull !== undefined) {
416
+ state.pullResolve = undefined;
417
+ this.#settleRead(state, undefined);
418
+ pull();
419
+ }
420
+ this.#maybeRetire(state);
421
+ return;
422
+ }
423
+ case FrameType.RESET:
424
+ return this.#failStream(state, new StreamReset(decodeUtf8(frame.payload)));
425
+ case FrameType.WINDOW: {
426
+ if (frame.payload.length !== WINDOW_PAYLOAD_BYTES) {
427
+ throw new UplinkProtocolError(`WINDOW payload must be ${WINDOW_PAYLOAD_BYTES} bytes`);
428
+ }
429
+ const credit = decodeU32(frame.payload);
430
+ // Credit is granted against bytes consumed, so it can never exceed
431
+ // what this side still has outstanding.
432
+ if (credit === 0 || credit > state.sendOutstanding) {
433
+ throw new UplinkProtocolError(
434
+ `stream ${id} credited ${credit} against ${state.sendOutstanding} outstanding bytes`,
435
+ );
436
+ }
437
+ state.sendCredit += credit;
438
+ state.sendOutstanding -= credit;
439
+ this.#inFlight -= credit;
440
+ this.#creditGate.wake();
441
+ return;
442
+ }
443
+ default:
444
+ throw new UplinkProtocolError(`${name} is not a stream frame`);
445
+ }
446
+ }
447
+
448
+ #handleData(state: StreamState, payload: Uint8Array): void {
449
+ if (state.recvEnded) throw new UplinkProtocolError(`DATA after END on stream ${state.id}`);
450
+ const size = payload.length;
451
+ if (size > state.recvRemaining) {
452
+ throw new UplinkProtocolError(`stream ${state.id} exceeded its receive window`);
453
+ }
454
+ if (this.#peerInFlight + size > this.#limits.maxInFlight) {
455
+ throw new UplinkProtocolError("peer exceeded the session in-flight cap");
456
+ }
457
+ state.recvRemaining -= size;
458
+ state.recvUncredited += size;
459
+ this.#peerInFlight += size;
460
+ if (size === 0) return;
461
+ const pull = state.pullResolve;
462
+ if (pull === undefined) {
463
+ state.recvQueue.push(payload);
464
+ return;
465
+ }
466
+ state.pullResolve = undefined;
467
+ this.#deliver(state, payload);
468
+ pull();
469
+ }
470
+
471
+ // --- stream plumbing -------------------------------------------------
472
+
473
+ #createStream(id: number, local: boolean): StreamState {
474
+ const state: StreamState = {
475
+ id,
476
+ local,
477
+ stream: undefined as unknown as Stream,
478
+ retired: false,
479
+ error: undefined,
480
+ sendCredit: this.#limits.initialWindow,
481
+ sendOutstanding: 0,
482
+ writeEnded: false,
483
+ writeController: undefined,
484
+ recvRemaining: this.#limits.initialWindow,
485
+ recvUncredited: 0,
486
+ consumed: 0,
487
+ recvQueue: [],
488
+ recvEnded: false,
489
+ readSettled: false,
490
+ readController: undefined,
491
+ pullResolve: undefined,
492
+ };
493
+ // A zero high-water mark is what ties credit to consumption: `pull` runs
494
+ // only when the application actually has a read outstanding.
495
+ const readable = new ReadableStream<Uint8Array>(
496
+ {
497
+ start: (controller) => {
498
+ state.readController = controller;
499
+ },
500
+ pull: () => this.#pull(state),
501
+ cancel: (reason) => {
502
+ // Nothing else can tell the peer to stop sending.
503
+ this.#resetStream(state, reasonText(reason));
504
+ },
505
+ },
506
+ { highWaterMark: 0 },
507
+ );
508
+ const writable = new WritableStream<Uint8Array>({
509
+ start: (controller) => {
510
+ state.writeController = controller;
511
+ },
512
+ write: (chunk) => this.#write(state, chunk),
513
+ close: () => {
514
+ this.#endWrite(state);
515
+ },
516
+ abort: (reason) => {
517
+ this.#resetStream(state, reasonText(reason));
518
+ },
519
+ });
520
+ state.stream = {
521
+ id,
522
+ readable,
523
+ writable,
524
+ reset: (reason: string) => {
525
+ this.#resetStream(state, reason);
526
+ },
527
+ };
528
+ this.#streams.set(id, state);
529
+ return state;
530
+ }
531
+
532
+ #pull(state: StreamState): Promise<void> | undefined {
533
+ const chunk = state.recvQueue.shift();
534
+ if (chunk !== undefined) {
535
+ this.#deliver(state, chunk);
536
+ return undefined;
537
+ }
538
+ if (state.error !== undefined) {
539
+ this.#settleRead(state, state.error);
540
+ return undefined;
541
+ }
542
+ if (state.recvEnded) {
543
+ this.#settleRead(state, undefined);
544
+ return undefined;
545
+ }
546
+ return new Promise<void>((resolve) => {
547
+ state.pullResolve = resolve;
548
+ });
549
+ }
550
+
551
+ #deliver(state: StreamState, chunk: Uint8Array): void {
552
+ if (state.readSettled) return;
553
+ state.readController?.enqueue(chunk);
554
+ this.#grant(state, chunk.length);
555
+ }
556
+
557
+ // Credit follows what the application read, not what arrived.
558
+ #grant(state: StreamState, size: number): void {
559
+ state.consumed += size;
560
+ if (state.retired || state.consumed < this.#grantThreshold) return;
561
+ const credit = state.consumed;
562
+ state.consumed = 0;
563
+ state.recvRemaining += credit;
564
+ state.recvUncredited -= credit;
565
+ this.#peerInFlight -= credit;
566
+ this.#sendControl(encodeFrame(FrameType.WINDOW, state.id, encodeU32(credit)), state.id);
567
+ }
568
+
569
+ #settleRead(state: StreamState, error: Error | undefined): void {
570
+ if (state.readSettled) return;
571
+ state.readSettled = true;
572
+ if (error === undefined) state.readController?.close();
573
+ else state.readController?.error(error);
574
+ }
575
+
576
+ #failStream(state: StreamState, error: Error): void {
577
+ if (state.retired) return;
578
+ state.error = error;
579
+ state.writeEnded = true;
580
+ state.recvEnded = true;
581
+ // Queued DATA for a stream the peer has abandoned is dead weight; the
582
+ // RESET has already overtaken it.
583
+ for (let i = this.#dataQueue.length - 1; i >= 0; i--) {
584
+ const entry = this.#dataQueue[i];
585
+ if (entry !== undefined && entry.streamId === state.id) {
586
+ this.#dataQueue.splice(i, 1);
587
+ entry.reject(error);
588
+ }
589
+ }
590
+ this.#settleRead(state, error);
591
+ const pull = state.pullResolve;
592
+ state.pullResolve = undefined;
593
+ pull?.();
594
+ state.writeController?.error(error);
595
+ this.#retire(state);
596
+ }
597
+
598
+ #maybeRetire(state: StreamState): void {
599
+ if (state.writeEnded && state.recvEnded) this.#retire(state);
600
+ }
601
+
602
+ // Frees the slot and the credit the stream held. The readable may still
603
+ // have buffered bytes to hand out; that is the application's business.
604
+ #retire(state: StreamState): void {
605
+ if (state.retired) return;
606
+ state.retired = true;
607
+ this.#inFlight -= state.sendOutstanding;
608
+ state.sendOutstanding = 0;
609
+ this.#peerInFlight -= state.recvUncredited;
610
+ state.recvUncredited = 0;
611
+ this.#streams.delete(state.id);
612
+ if (state.local) this.#localOpen--;
613
+ else this.#remoteOpen--;
614
+ this.#slotGate.wake();
615
+ this.#creditGate.wake();
616
+ }
617
+
618
+ // --- teardown --------------------------------------------------------
619
+
620
+ #shutdown(error: Error, reason: string): void {
621
+ if (this.#closeError !== undefined) return;
622
+ this.#closeError = error;
623
+ for (const entry of this.#controlQueue.splice(0)) entry.reject(error);
624
+ for (const entry of this.#dataQueue.splice(0)) entry.reject(error);
625
+ // Snapshot: failing a stream retires it out of the map.
626
+ for (const state of Array.from(this.#streams.values())) this.#failStream(state, error);
627
+ for (const waiter of this.#pings.values()) waiter.reject(error);
628
+ this.#pings.clear();
629
+ this.#creditGate.wake();
630
+ this.#slotGate.wake();
631
+ this.#closedResolve(reason);
632
+ void this.#writer.abort(reason).catch(noop);
633
+ void this.#reader.cancel(reason).catch(noop);
634
+ }
635
+ }
636
+
637
+ function noop(): void {}
638
+
639
+ function asError(value: unknown): Error {
640
+ return value instanceof Error ? value : new Error(String(value));
641
+ }
642
+
643
+ function reasonText(reason: unknown): string {
644
+ if (reason === undefined || reason === null) return "";
645
+ return reason instanceof Error ? reason.message : String(reason);
646
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The version of the RPC catalogue in `methods.ts`, as both peers announce it
3
+ * in `hello`. Bump it whenever any method OTHER than `hello` changes shape -
4
+ * params or result, tightened or loosened. `protocol.test.ts` snapshots the
5
+ * catalogue under this number and fails when the two disagree.
6
+ *
7
+ * `hello` itself is outside this number on purpose: it is the door, and a
8
+ * door both sides can always open is what lets a mismatched pair be told to
9
+ * update instead of being stranded.
10
+ */
11
+ export const PROTOCOL_VERSION = 1;