@structured-id/ui-core 0.1.5

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 (156) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +47 -0
  3. package/dist/browser-vp/index.cjs +349 -0
  4. package/dist/browser-vp/index.d.cts +287 -0
  5. package/dist/browser-vp/index.d.ts +287 -0
  6. package/dist/browser-vp/index.js +349 -0
  7. package/dist/chunk-C3Q766CY.cjs +125 -0
  8. package/dist/chunk-J5YXRXB5.js +125 -0
  9. package/dist/index.cjs +19360 -0
  10. package/dist/index.d.cts +9536 -0
  11. package/dist/index.d.ts +9536 -0
  12. package/dist/index.js +19360 -0
  13. package/dist/storage-4CB2JTLW.cjs +22 -0
  14. package/dist/storage-THRWREZP.js +22 -0
  15. package/package.json +93 -0
  16. package/src/browser-vp/crypto.ts +254 -0
  17. package/src/browser-vp/index.test.ts +10 -0
  18. package/src/browser-vp/index.ts +118 -0
  19. package/src/browser-vp/prf.ts +181 -0
  20. package/src/browser-vp/storage.test.ts +9 -0
  21. package/src/browser-vp/storage.ts +165 -0
  22. package/src/browser-vp/types.ts +97 -0
  23. package/src/browser-vp/vp.test.ts +43 -0
  24. package/src/browser-vp/vp.ts +113 -0
  25. package/src/components/SidAvatar.vue +106 -0
  26. package/src/components/SidConfirmDialog.vue +125 -0
  27. package/src/components/SidStatusBadge.vue +71 -0
  28. package/src/components/index.ts +5 -0
  29. package/src/composables/index.ts +10 -0
  30. package/src/composables/useAuth.ts +40 -0
  31. package/src/composables/useBffAuth.ts +147 -0
  32. package/src/composables/useGrpc.test.ts +196 -0
  33. package/src/composables/useGrpc.ts +120 -0
  34. package/src/composables/useSession.ts +80 -0
  35. package/src/generated/google/api/http.ts +687 -0
  36. package/src/generated/google/protobuf/descriptor.ts +4865 -0
  37. package/src/generated/google/protobuf/duration.ts +230 -0
  38. package/src/generated/google/protobuf/empty.ts +94 -0
  39. package/src/generated/google/protobuf/struct.ts +484 -0
  40. package/src/generated/google/protobuf/timestamp.ts +287 -0
  41. package/src/generated/sid/v1/account/account.client.ts +527 -0
  42. package/src/generated/sid/v1/account/account.ts +3261 -0
  43. package/src/generated/sid/v1/admin/admin.client.ts +136 -0
  44. package/src/generated/sid/v1/admin/admin.ts +846 -0
  45. package/src/generated/sid/v1/admin/admin_roles.client.ts +181 -0
  46. package/src/generated/sid/v1/admin/admin_roles.ts +1606 -0
  47. package/src/generated/sid/v1/admin/analytics.client.ts +106 -0
  48. package/src/generated/sid/v1/admin/analytics.ts +1017 -0
  49. package/src/generated/sid/v1/admin/branding.client.ts +166 -0
  50. package/src/generated/sid/v1/admin/branding.ts +1906 -0
  51. package/src/generated/sid/v1/admin/enrollment.client.ts +165 -0
  52. package/src/generated/sid/v1/admin/enrollment.ts +1704 -0
  53. package/src/generated/sid/v1/admin/events.client.ts +195 -0
  54. package/src/generated/sid/v1/admin/events.ts +1900 -0
  55. package/src/generated/sid/v1/admin/flow.client.ts +183 -0
  56. package/src/generated/sid/v1/admin/flow.ts +1552 -0
  57. package/src/generated/sid/v1/admin/keys.client.ts +107 -0
  58. package/src/generated/sid/v1/admin/keys.ts +931 -0
  59. package/src/generated/sid/v1/admin/login_experience.client.ts +160 -0
  60. package/src/generated/sid/v1/admin/login_experience.ts +1061 -0
  61. package/src/generated/sid/v1/admin/migration.client.ts +127 -0
  62. package/src/generated/sid/v1/admin/migration.ts +1948 -0
  63. package/src/generated/sid/v1/admin/notifications.client.ts +128 -0
  64. package/src/generated/sid/v1/admin/notifications.ts +1332 -0
  65. package/src/generated/sid/v1/admin/operations.client.ts +241 -0
  66. package/src/generated/sid/v1/admin/operations.ts +2648 -0
  67. package/src/generated/sid/v1/admin/organizations.client.ts +57 -0
  68. package/src/generated/sid/v1/admin/organizations.ts +349 -0
  69. package/src/generated/sid/v1/admin/scopes.client.ts +171 -0
  70. package/src/generated/sid/v1/admin/scopes.ts +1324 -0
  71. package/src/generated/sid/v1/admin/security.client.ts +412 -0
  72. package/src/generated/sid/v1/admin/security.ts +4086 -0
  73. package/src/generated/sid/v1/admin/sessions.client.ts +114 -0
  74. package/src/generated/sid/v1/admin/sessions.ts +923 -0
  75. package/src/generated/sid/v1/admin/settings.client.ts +340 -0
  76. package/src/generated/sid/v1/admin/settings.ts +2395 -0
  77. package/src/generated/sid/v1/admin/user_profile.client.ts +57 -0
  78. package/src/generated/sid/v1/admin/user_profile.ts +730 -0
  79. package/src/generated/sid/v1/admin/webhooks.client.ts +269 -0
  80. package/src/generated/sid/v1/admin/webhooks.ts +2804 -0
  81. package/src/generated/sid/v1/attestation/attestation.client.ts +115 -0
  82. package/src/generated/sid/v1/attestation/attestation.ts +813 -0
  83. package/src/generated/sid/v1/authn/auth.client.ts +900 -0
  84. package/src/generated/sid/v1/authn/auth.ts +9287 -0
  85. package/src/generated/sid/v1/authn/flows.client.ts +413 -0
  86. package/src/generated/sid/v1/authn/flows.ts +3217 -0
  87. package/src/generated/sid/v1/authz/authz.client.ts +459 -0
  88. package/src/generated/sid/v1/authz/authz.ts +5343 -0
  89. package/src/generated/sid/v1/authz/governance.client.ts +102 -0
  90. package/src/generated/sid/v1/authz/governance.ts +818 -0
  91. package/src/generated/sid/v1/common/errors.ts +184 -0
  92. package/src/generated/sid/v1/common/pagination.ts +152 -0
  93. package/src/generated/sid/v1/common/transport.ts +227 -0
  94. package/src/generated/sid/v1/common/types.ts +151 -0
  95. package/src/generated/sid/v1/events/event_stream.client.ts +67 -0
  96. package/src/generated/sid/v1/events/event_stream.ts +374 -0
  97. package/src/generated/sid/v1/federation/upstream.client.ts +142 -0
  98. package/src/generated/sid/v1/federation/upstream.ts +1736 -0
  99. package/src/generated/sid/v1/identity/identity.client.ts +467 -0
  100. package/src/generated/sid/v1/identity/identity.ts +5613 -0
  101. package/src/generated/sid/v1/machine/machine_user.client.ts +219 -0
  102. package/src/generated/sid/v1/machine/machine_user.ts +2400 -0
  103. package/src/generated/sid/v1/machine/pat.client.ts +147 -0
  104. package/src/generated/sid/v1/machine/pat.ts +1268 -0
  105. package/src/generated/sid/v1/projects/projects.client.ts +286 -0
  106. package/src/generated/sid/v1/projects/projects.ts +2714 -0
  107. package/src/generated/sid/v1/scim/scim.client.ts +274 -0
  108. package/src/generated/sid/v1/scim/scim.ts +3043 -0
  109. package/src/generated/sid/v1/test/test.client.ts +232 -0
  110. package/src/generated/sid/v1/test/test.ts +1136 -0
  111. package/src/i18n/index.ts +40 -0
  112. package/src/index.ts +157 -0
  113. package/src/profile/auth.ts +15 -0
  114. package/src/profile/components/ConsentManager.vue +88 -0
  115. package/src/profile/components/ProfileEditor.vue +146 -0
  116. package/src/profile/components/SecuritySettings.vue +170 -0
  117. package/src/profile/components/SessionList.vue +100 -0
  118. package/src/profile/components/SidLoginForm.test.ts +80 -0
  119. package/src/profile/components/SidLoginForm.vue +230 -0
  120. package/src/profile/components/SidMfaChallenge.test.ts +127 -0
  121. package/src/profile/components/SidMfaChallenge.vue +243 -0
  122. package/src/profile/components/SidRegistrationForm.test.ts +79 -0
  123. package/src/profile/components/SidRegistrationForm.vue +317 -0
  124. package/src/profile/components/index.ts +11 -0
  125. package/src/profile/composables/index.ts +6 -0
  126. package/src/profile/composables/useAccountApi.ts +203 -0
  127. package/src/profile/composables/useAuthApi.opaque.test.ts +140 -0
  128. package/src/profile/composables/useAuthApi.ts +617 -0
  129. package/src/profile/composables/useIdentityApi.ts +123 -0
  130. package/src/profile/composables/useLoginSuccess.test.ts +112 -0
  131. package/src/profile/composables/useLoginSuccess.ts +65 -0
  132. package/src/profile/composables/useOpaqueAuth.test.ts +290 -0
  133. package/src/profile/composables/useOpaqueAuth.ts +191 -0
  134. package/src/profile/composables/useSecurityLevel.test.ts +198 -0
  135. package/src/profile/composables/useSecurityLevel.ts +324 -0
  136. package/src/profile/index.ts +18 -0
  137. package/src/quasar/SidPrincipalInput.vue +209 -0
  138. package/src/quasar/index.ts +8 -0
  139. package/src/stores/auth.test.ts +83 -0
  140. package/src/stores/auth.ts +92 -0
  141. package/src/stores/index.ts +1 -0
  142. package/src/transport/adaptive.test.ts +728 -0
  143. package/src/transport/adaptive.ts +308 -0
  144. package/src/transport/framing.test.ts +227 -0
  145. package/src/transport/framing.ts +98 -0
  146. package/src/transport/webtransport.test.ts +728 -0
  147. package/src/transport/webtransport.ts +323 -0
  148. package/src/types/auth.ts +58 -0
  149. package/src/types/index.ts +1 -0
  150. package/src/utils/formatters.test.ts +26 -0
  151. package/src/utils/formatters.ts +69 -0
  152. package/src/utils/index.ts +16 -0
  153. package/src/utils/normalizePrincipal.test.ts +188 -0
  154. package/src/utils/normalizePrincipal.ts +159 -0
  155. package/src/utils/validators.test.ts +75 -0
  156. package/src/utils/validators.ts +36 -0
@@ -0,0 +1,308 @@
1
+ /**
2
+ * AdaptiveRpcTransport — per-call failover between WebTransport and gRPC-web.
3
+ *
4
+ * Strategy:
5
+ * 1. Try primary (WebTransport) for every call
6
+ * 2. On transport-level failure (UNAVAILABLE), immediately retry on fallback (gRPC-web)
7
+ * 3. Background reconnect to primary after failover
8
+ * 4. Once primary is back — switch future calls to primary again
9
+ *
10
+ * This is NOT a one-time negotiation. Each call can roam independently.
11
+ */
12
+
13
+ import {
14
+ Deferred,
15
+ RpcError,
16
+ mergeRpcOptions,
17
+ UnaryCall,
18
+ } from "@protobuf-ts/runtime-rpc";
19
+ import type {
20
+ RpcTransport,
21
+ MethodInfo,
22
+ RpcOptions,
23
+ RpcMetadata,
24
+ RpcStatus,
25
+ ServerStreamingCall,
26
+ ClientStreamingCall,
27
+ DuplexStreamingCall,
28
+ } from "@protobuf-ts/runtime-rpc";
29
+ import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport";
30
+ import {
31
+ WebTransportConnection,
32
+ WebTransportRpcTransport,
33
+ } from "./webtransport";
34
+
35
+ export type TransportType = "webtransport" | "grpc-web";
36
+
37
+ export interface AdaptiveTransportOptions {
38
+ /** gRPC-web base URL (required, always available as fallback) */
39
+ grpcWebUrl: string;
40
+ /** WebTransport URL (optional — if absent, always uses gRPC-web) */
41
+ webTransportUrl?: string;
42
+ /** SHA-256 cert hash for dev WebTransport certs */
43
+ webTransportCertHash?: string;
44
+ /** Delay before attempting to reconnect primary after failover (ms) */
45
+ reconnectDelay?: number;
46
+ /** Callback when active transport changes */
47
+ onTransportChange?: (transport: TransportType) => void;
48
+ /**
49
+ * Fetch init options for the gRPC-web fallback transport.
50
+ * Use `{ credentials: "include" }` to send BFF session cookies.
51
+ */
52
+ fetchInit?: RequestInit;
53
+ }
54
+
55
+ /**
56
+ * Adaptive RPC transport with per-call failover and background reconnect.
57
+ */
58
+ export class AdaptiveRpcTransport implements RpcTransport {
59
+ private readonly defaultOptions: RpcOptions;
60
+ private readonly fallback: GrpcWebFetchTransport;
61
+ private readonly wtConnection: WebTransportConnection | null;
62
+ private readonly wtTransport: WebTransportRpcTransport | null;
63
+ private readonly reconnectDelay: number;
64
+ private readonly onTransportChange?: (transport: TransportType) => void;
65
+
66
+ private primaryAvailable = false;
67
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
68
+
69
+ constructor(opts: AdaptiveTransportOptions, defaultOptions?: RpcOptions) {
70
+ this.defaultOptions = defaultOptions ?? {};
71
+ this.reconnectDelay = opts.reconnectDelay ?? 5000;
72
+ this.onTransportChange = opts.onTransportChange;
73
+
74
+ this.fallback = new GrpcWebFetchTransport({
75
+ baseUrl: opts.grpcWebUrl,
76
+ fetchInit: opts.fetchInit,
77
+ });
78
+
79
+ if (opts.webTransportUrl && typeof WebTransport !== "undefined") {
80
+ this.wtConnection = new WebTransportConnection({
81
+ url: opts.webTransportUrl,
82
+ certHash: opts.webTransportCertHash,
83
+ });
84
+ this.wtTransport = new WebTransportRpcTransport(this.wtConnection);
85
+ } else {
86
+ this.wtConnection = null;
87
+ this.wtTransport = null;
88
+ }
89
+ }
90
+
91
+ /** Current active transport type. */
92
+ get activeTransport(): TransportType {
93
+ return this.primaryAvailable ? "webtransport" : "grpc-web";
94
+ }
95
+
96
+ /**
97
+ * Attempt initial WebTransport connection.
98
+ * Call this during app boot — non-blocking, falls back silently.
99
+ */
100
+ async init(): Promise<void> {
101
+ if (!this.wtConnection) {
102
+ this.notifyTransport("grpc-web");
103
+ return;
104
+ }
105
+
106
+ try {
107
+ await this.wtConnection.connect();
108
+ this.primaryAvailable = true;
109
+ this.notifyTransport("webtransport");
110
+ } catch {
111
+ this.primaryAvailable = false;
112
+ this.notifyTransport("grpc-web");
113
+ this.scheduleReconnect();
114
+ }
115
+ }
116
+
117
+ /** Shut down all connections. */
118
+ close(): void {
119
+ if (this.reconnectTimer) {
120
+ clearTimeout(this.reconnectTimer);
121
+ this.reconnectTimer = null;
122
+ }
123
+ this.wtConnection?.close();
124
+ }
125
+
126
+ mergeOptions(options?: Partial<RpcOptions>): RpcOptions {
127
+ return mergeRpcOptions(this.defaultOptions, options);
128
+ }
129
+
130
+ unary<I extends object, O extends object>(
131
+ method: MethodInfo<I, O>,
132
+ input: I,
133
+ options: RpcOptions,
134
+ ): UnaryCall<I, O> {
135
+ // If primary available — try it first with fallback on failure
136
+ if (this.primaryAvailable && this.wtTransport) {
137
+ return this.unaryWithFallback(method, input, options);
138
+ }
139
+
140
+ // Otherwise — go directly to fallback
141
+ return this.fallback.unary(method, input, options);
142
+ }
143
+
144
+ serverStreaming<I extends object, O extends object>(
145
+ method: MethodInfo<I, O>,
146
+ input: I,
147
+ options: RpcOptions,
148
+ ): ServerStreamingCall<I, O> {
149
+ // Streaming only via gRPC-web (WebTransport doesn't support it yet)
150
+ return this.fallback.serverStreaming(method, input, options);
151
+ }
152
+
153
+ clientStreaming<I extends object, O extends object>(
154
+ _method: MethodInfo<I, O>,
155
+ _options: RpcOptions,
156
+ ): ClientStreamingCall<I, O> {
157
+ throw new RpcError("Client streaming not supported");
158
+ }
159
+
160
+ duplex<I extends object, O extends object>(
161
+ _method: MethodInfo<I, O>,
162
+ _options: RpcOptions,
163
+ ): DuplexStreamingCall<I, O> {
164
+ throw new RpcError("Duplex streaming not supported");
165
+ }
166
+
167
+ /**
168
+ * Try primary, on transport-level error retry with fallback.
169
+ *
170
+ * The tricky part: UnaryCall is returned synchronously (with deferred promises).
171
+ * We wrap both attempts and resolve/reject the outer deferreds.
172
+ */
173
+ private unaryWithFallback<I extends object, O extends object>(
174
+ method: MethodInfo<I, O>,
175
+ input: I,
176
+ options: RpcOptions,
177
+ ): UnaryCall<I, O> {
178
+ const defHeader = new Deferred<RpcMetadata>();
179
+ const defMessage = new Deferred<O>();
180
+ const defStatus = new Deferred<RpcStatus>();
181
+ const defTrailer = new Deferred<RpcMetadata>();
182
+
183
+ const call = new UnaryCall(
184
+ method,
185
+ options.meta ?? {},
186
+ input,
187
+ defHeader.promise,
188
+ defMessage.promise,
189
+ defStatus.promise,
190
+ defTrailer.promise,
191
+ );
192
+
193
+ this.tryPrimaryThenFallback(
194
+ method,
195
+ input,
196
+ options,
197
+ defHeader,
198
+ defMessage,
199
+ defStatus,
200
+ defTrailer,
201
+ );
202
+
203
+ return call;
204
+ }
205
+
206
+ private async tryPrimaryThenFallback<I extends object, O extends object>(
207
+ method: MethodInfo<I, O>,
208
+ input: I,
209
+ options: RpcOptions,
210
+ defHeader: Deferred<RpcMetadata>,
211
+ defMessage: Deferred<O>,
212
+ defStatus: Deferred<RpcStatus>,
213
+ defTrailer: Deferred<RpcMetadata>,
214
+ ): Promise<void> {
215
+ try {
216
+ // Attempt primary (WebTransport)
217
+ const primaryCall = this.wtTransport!.unary(method, input, options);
218
+ const [header, message, status, trailer] = await Promise.all([
219
+ primaryCall.headers,
220
+ primaryCall.response,
221
+ primaryCall.status,
222
+ primaryCall.trailers,
223
+ ]);
224
+
225
+ defHeader.resolve(header);
226
+ defMessage.resolve(message);
227
+ defStatus.resolve(status);
228
+ defTrailer.resolve(trailer);
229
+ } catch (err) {
230
+ // Transport-level failure — failover to gRPC-web
231
+ if (isTransportError(err)) {
232
+ this.primaryAvailable = false;
233
+ this.notifyTransport("grpc-web");
234
+ this.scheduleReconnect();
235
+
236
+ try {
237
+ const fallbackCall = this.fallback.unary(method, input, options);
238
+ const [header, message, status, trailer] = await Promise.all([
239
+ fallbackCall.headers,
240
+ fallbackCall.response,
241
+ fallbackCall.status,
242
+ fallbackCall.trailers,
243
+ ]);
244
+
245
+ defHeader.resolvePending(header);
246
+ defMessage.resolvePending(message);
247
+ defStatus.resolvePending(status);
248
+ defTrailer.resolvePending(trailer);
249
+ } catch (fallbackErr) {
250
+ rejectAll(fallbackErr, defHeader, defMessage, defStatus, defTrailer);
251
+ }
252
+ } else {
253
+ // Application-level error (e.g. NOT_FOUND, PERMISSION_DENIED)
254
+ // — don't failover, propagate as-is
255
+ rejectAll(err, defHeader, defMessage, defStatus, defTrailer);
256
+ }
257
+ }
258
+ }
259
+
260
+ private scheduleReconnect(): void {
261
+ if (this.reconnectTimer || !this.wtConnection) return;
262
+
263
+ this.reconnectTimer = setTimeout(async () => {
264
+ this.reconnectTimer = null;
265
+ try {
266
+ this.wtConnection!.close();
267
+ await this.wtConnection!.connect();
268
+ this.primaryAvailable = true;
269
+ this.notifyTransport("webtransport");
270
+ } catch {
271
+ // Still down — try again later
272
+ this.scheduleReconnect();
273
+ }
274
+ }, this.reconnectDelay);
275
+ }
276
+
277
+ private notifyTransport(type: TransportType): void {
278
+ this.onTransportChange?.(type);
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Check if the error is a transport-level failure (connection lost,
284
+ * stream reset, etc.) vs an application-level gRPC error.
285
+ *
286
+ * Transport errors trigger failover. Application errors do not.
287
+ */
288
+ function isTransportError(err: unknown): boolean {
289
+ if (!(err instanceof RpcError)) return true;
290
+
291
+ // gRPC UNAVAILABLE = server unreachable (transport issue)
292
+ // gRPC CANCELLED = connection dropped mid-call
293
+ const transportCodes = ["UNAVAILABLE", "CANCELLED", "UNKNOWN"];
294
+ return transportCodes.includes(err.code);
295
+ }
296
+
297
+ function rejectAll(err: unknown, ...deferreds: Deferred<unknown>[]): void {
298
+ const rpcErr =
299
+ err instanceof RpcError
300
+ ? err
301
+ : new RpcError(
302
+ err instanceof Error ? err.message : String(err),
303
+ "INTERNAL",
304
+ );
305
+ for (const d of deferreds) {
306
+ d.rejectPending(rpcErr);
307
+ }
308
+ }
@@ -0,0 +1,227 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFrame, writeFrame, FrameReader } from "./framing";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Helpers — mock ReadableStream / WritableStream
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /**
9
+ * Create a ReadableStreamDefaultReader that yields chunks sequentially.
10
+ */
11
+ function makeReader(
12
+ chunks: Uint8Array[],
13
+ ): ReadableStreamDefaultReader<Uint8Array> {
14
+ let i = 0;
15
+ return {
16
+ read: async () => {
17
+ if (i < chunks.length) {
18
+ return { value: chunks[i++], done: false };
19
+ }
20
+ return { value: undefined, done: true };
21
+ },
22
+ releaseLock: () => {},
23
+ cancel: async () => {},
24
+ closed: Promise.resolve(undefined),
25
+ } as unknown as ReadableStreamDefaultReader<Uint8Array>;
26
+ }
27
+
28
+ /**
29
+ * Create a WritableStreamDefaultWriter that collects all written bytes.
30
+ * The `chunks` array is populated on each write() call.
31
+ */
32
+ function makeWriter(
33
+ chunks: Uint8Array[],
34
+ ): WritableStreamDefaultWriter<Uint8Array> {
35
+ return {
36
+ write: async (chunk: Uint8Array) => {
37
+ chunks.push(chunk);
38
+ },
39
+ releaseLock: () => {},
40
+ close: async () => {},
41
+ abort: async () => {},
42
+ closed: Promise.resolve(undefined),
43
+ desiredSize: 1,
44
+ ready: Promise.resolve(undefined),
45
+ } as unknown as WritableStreamDefaultWriter<Uint8Array>;
46
+ }
47
+
48
+ /**
49
+ * Concatenate all chunks into a single Uint8Array.
50
+ */
51
+ function concat(chunks: Uint8Array[]): Uint8Array {
52
+ const total = chunks.reduce((n, c) => n + c.length, 0);
53
+ const out = new Uint8Array(total);
54
+ let offset = 0;
55
+ for (const c of chunks) {
56
+ out.set(c, offset);
57
+ offset += c.length;
58
+ }
59
+ return out;
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Tests
64
+ // ---------------------------------------------------------------------------
65
+
66
+ describe("writeFrame", () => {
67
+ it("writes 4-byte big-endian length prefix followed by payload", async () => {
68
+ const payload = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
69
+ const chunks: Uint8Array[] = [];
70
+ const writer = makeWriter(chunks);
71
+
72
+ await writeFrame(writer, payload);
73
+
74
+ // Should write exactly 2 chunks: length prefix + payload
75
+ expect(chunks).toHaveLength(2);
76
+
77
+ const lenBytes = chunks[0];
78
+ expect(lenBytes).toHaveLength(4);
79
+ const view = new DataView(lenBytes.buffer);
80
+ expect(view.getUint32(0, false)).toBe(4); // big-endian length = 4
81
+
82
+ expect(chunks[1]).toEqual(payload);
83
+ });
84
+
85
+ it("writes zero-length frame for empty payload", async () => {
86
+ const payload = new Uint8Array(0);
87
+ const chunks: Uint8Array[] = [];
88
+ await writeFrame(makeWriter(chunks), payload);
89
+
90
+ const view = new DataView(chunks[0].buffer);
91
+ expect(view.getUint32(0, false)).toBe(0);
92
+ expect(chunks[1]).toHaveLength(0);
93
+ });
94
+
95
+ it("encodes length in big-endian (MSB first)", async () => {
96
+ // 0x0102_0304 = 16909060 bytes would OOM — use a smaller payload and verify byte order
97
+ const smallPayload = new Uint8Array(258); // 0x0000_0102
98
+ const chunks: Uint8Array[] = [];
99
+ await writeFrame(makeWriter(chunks), smallPayload);
100
+
101
+ const lenBytes = chunks[0];
102
+ expect(lenBytes[0]).toBe(0x00);
103
+ expect(lenBytes[1]).toBe(0x00);
104
+ expect(lenBytes[2]).toBe(0x01);
105
+ expect(lenBytes[3]).toBe(0x02);
106
+ });
107
+ });
108
+
109
+ describe("readFrame", () => {
110
+ it("reads a frame written by writeFrame (roundtrip)", async () => {
111
+ const payload = new Uint8Array([1, 2, 3, 4, 5]);
112
+
113
+ // Write a frame to get the raw bytes
114
+ const chunks: Uint8Array[] = [];
115
+ await writeFrame(makeWriter(chunks), payload);
116
+ const raw = concat(chunks);
117
+
118
+ // Split into multiple chunks to test buffering (reader delivers byte-by-byte)
119
+ const byteChunks = Array.from(raw).map((b) => new Uint8Array([b]));
120
+ const reader = makeReader(byteChunks);
121
+
122
+ const result = await readFrame(reader);
123
+ expect(result).toEqual(payload);
124
+ });
125
+
126
+ it("reads frame when header and payload each arrive in one read", async () => {
127
+ // WebTransport delivers frame-aligned chunks: writeFrame writes 2 chunks
128
+ // (4-byte length prefix, then payload). readFrame receives them as 2 reads.
129
+ const payload = new Uint8Array([0xca, 0xfe]);
130
+ const chunks: Uint8Array[] = [];
131
+ await writeFrame(makeWriter(chunks), payload);
132
+ // chunks[0] = 4-byte length prefix, chunks[1] = payload
133
+ const reader = makeReader([chunks[0], chunks[1]]);
134
+ const result = await readFrame(reader);
135
+ expect(result).toEqual(payload);
136
+ });
137
+
138
+ it("reads frame when bytes arrive in two chunks (header separate from body)", async () => {
139
+ const payload = new Uint8Array([10, 20, 30]);
140
+ const chunks: Uint8Array[] = [];
141
+ await writeFrame(makeWriter(chunks), payload);
142
+ // chunks[0] = 4-byte header, chunks[1] = payload
143
+ const reader = makeReader([chunks[0], chunks[1]]);
144
+
145
+ const result = await readFrame(reader);
146
+ expect(result).toEqual(payload);
147
+ });
148
+
149
+ it("reads zero-length frame", async () => {
150
+ const payload = new Uint8Array(0);
151
+ const chunks: Uint8Array[] = [];
152
+ await writeFrame(makeWriter(chunks), payload);
153
+ const raw = concat(chunks);
154
+
155
+ const reader = makeReader([raw]);
156
+ const result = await readFrame(reader);
157
+ expect(result).toHaveLength(0);
158
+ });
159
+
160
+ it("throws when stream closes before frame complete", async () => {
161
+ // Only deliver the length prefix, no payload
162
+ const lenBuf = new Uint8Array(4);
163
+ new DataView(lenBuf.buffer).setUint32(0, 10, false); // says 10 bytes
164
+ const reader = makeReader([lenBuf]); // but no payload follows
165
+
166
+ await expect(readFrame(reader)).rejects.toThrow(
167
+ "Stream closed before frame complete",
168
+ );
169
+ });
170
+
171
+ it("throws when frame exceeds 4 MiB limit", async () => {
172
+ const lenBuf = new Uint8Array(4);
173
+ new DataView(lenBuf.buffer).setUint32(0, 4 * 1024 * 1024 + 1, false); // 4 MiB + 1
174
+ const reader = makeReader([lenBuf]);
175
+
176
+ await expect(readFrame(reader)).rejects.toThrow("Frame too large");
177
+ });
178
+ });
179
+
180
+ describe("FrameReader", () => {
181
+ it("reads two consecutive frames when each arrives in separate reads", async () => {
182
+ const p1 = new Uint8Array([1, 2, 3]);
183
+ const p2 = new Uint8Array([4, 5, 6, 7]);
184
+ const w1: Uint8Array[] = [];
185
+ const w2: Uint8Array[] = [];
186
+ await writeFrame(makeWriter(w1), p1);
187
+ await writeFrame(makeWriter(w2), p2);
188
+ // Deliver as separate chunks: [len1, p1, len2, p2]
189
+ const reader = makeReader([w1[0], w1[1], w2[0], w2[1]]);
190
+ const fr = new FrameReader(reader);
191
+
192
+ expect(await fr.readFrame()).toEqual(p1);
193
+ expect(await fr.readFrame()).toEqual(p2);
194
+ });
195
+
196
+ it("reads two consecutive frames when both arrive in a single chunk (QUIC coalescing)", async () => {
197
+ const p1 = new Uint8Array([0xaa, 0xbb]);
198
+ const p2 = new Uint8Array([0xcc, 0xdd, 0xee]);
199
+ const w1: Uint8Array[] = [];
200
+ const w2: Uint8Array[] = [];
201
+ await writeFrame(makeWriter(w1), p1);
202
+ await writeFrame(makeWriter(w2), p2);
203
+ // Deliver all bytes as a single chunk — simulates QUIC frame coalescing
204
+ const combined = concat([w1[0], w1[1], w2[0], w2[1]]);
205
+ const reader = makeReader([combined]);
206
+ const fr = new FrameReader(reader);
207
+
208
+ expect(await fr.readFrame()).toEqual(p1);
209
+ expect(await fr.readFrame()).toEqual(p2);
210
+ });
211
+
212
+ it("reads frames when first frame is split mid-payload across two chunks", async () => {
213
+ const p1 = new Uint8Array([10, 20, 30, 40]);
214
+ const p2 = new Uint8Array([50, 60]);
215
+ const w1: Uint8Array[] = [];
216
+ const w2: Uint8Array[] = [];
217
+ await writeFrame(makeWriter(w1), p1);
218
+ await writeFrame(makeWriter(w2), p2);
219
+ const full = concat([w1[0], w1[1], w2[0], w2[1]]);
220
+ // Split at mid-point of first frame
221
+ const reader = makeReader([full.slice(0, 5), full.slice(5)]);
222
+ const fr = new FrameReader(reader);
223
+
224
+ expect(await fr.readFrame()).toEqual(p1);
225
+ expect(await fr.readFrame()).toEqual(p2);
226
+ });
227
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Length-prefixed frame helpers for WebTransport streams.
3
+ *
4
+ * Wire format (per frame): [4 bytes big-endian length][payload]
5
+ */
6
+
7
+ const MAX_FRAME_SIZE = 4 * 1024 * 1024; // 4 MiB
8
+
9
+ /**
10
+ * Stateful frame reader that correctly buffers excess bytes between reads.
11
+ *
12
+ * WebTransport delivers QUIC stream data in arbitrary chunk boundaries — a
13
+ * single reader.read() may contain parts of multiple frames, or a frame may
14
+ * span multiple reads. FrameReader handles both cases correctly.
15
+ *
16
+ * Use FrameReader when reading multiple frames from the same stream.
17
+ */
18
+ export class FrameReader {
19
+ private leftover = new Uint8Array(0);
20
+
21
+ constructor(
22
+ private readonly reader: ReadableStreamDefaultReader<Uint8Array>,
23
+ ) {}
24
+
25
+ async readFrame(): Promise<Uint8Array> {
26
+ const lenBuf = await this.readExact(4);
27
+ const view = new DataView(lenBuf.buffer, lenBuf.byteOffset, 4);
28
+ const len = view.getUint32(0, false); // big-endian
29
+
30
+ if (len > MAX_FRAME_SIZE) {
31
+ throw new Error(`Frame too large: ${len} bytes (max ${MAX_FRAME_SIZE})`);
32
+ }
33
+
34
+ return this.readExact(len);
35
+ }
36
+
37
+ private async readExact(n: number): Promise<Uint8Array> {
38
+ const result = new Uint8Array(n);
39
+ let offset = 0;
40
+
41
+ // First drain leftover from previous read
42
+ if (this.leftover.length > 0) {
43
+ const take = Math.min(this.leftover.length, n);
44
+ result.set(this.leftover.subarray(0, take), offset);
45
+ offset += take;
46
+ this.leftover =
47
+ this.leftover.length > take
48
+ ? this.leftover.slice(take)
49
+ : new Uint8Array(0);
50
+ }
51
+
52
+ while (offset < n) {
53
+ const { value, done } = await this.reader.read();
54
+ if (done || !value) {
55
+ throw new Error("Stream closed before frame complete");
56
+ }
57
+
58
+ const remaining = n - offset;
59
+ if (value.length <= remaining) {
60
+ result.set(value, offset);
61
+ offset += value.length;
62
+ } else {
63
+ // Chunk contains more bytes than needed — save the tail for next call
64
+ result.set(value.subarray(0, remaining), offset);
65
+ offset += remaining;
66
+ this.leftover = value.slice(remaining);
67
+ }
68
+ }
69
+
70
+ return result;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Read one length-prefixed frame from a ReadableStreamDefaultReader.
76
+ *
77
+ * Convenience wrapper for reading a single frame from a fresh stream.
78
+ * When reading multiple consecutive frames from the same stream, use
79
+ * FrameReader directly so excess bytes are not lost between reads.
80
+ */
81
+ export async function readFrame(
82
+ reader: ReadableStreamDefaultReader<Uint8Array>,
83
+ ): Promise<Uint8Array> {
84
+ return new FrameReader(reader).readFrame();
85
+ }
86
+
87
+ /**
88
+ * Write one length-prefixed frame to a WritableStreamDefaultWriter.
89
+ */
90
+ export async function writeFrame(
91
+ writer: WritableStreamDefaultWriter<Uint8Array>,
92
+ payload: Uint8Array,
93
+ ): Promise<void> {
94
+ const lenBuf = new Uint8Array(4);
95
+ new DataView(lenBuf.buffer).setUint32(0, payload.length, false);
96
+ await writer.write(lenBuf);
97
+ await writer.write(payload);
98
+ }