@structured-id/ui-core 2.0.0 → 3.0.0

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 (152) hide show
  1. package/README.md +22 -10
  2. package/package.json +25 -43
  3. package/src/components/SidStatusBadge.vue +1 -5
  4. package/src/composables/useAccountTransport.test.ts +2 -1
  5. package/src/composables/useGrpc.test.ts +17 -14
  6. package/src/composables/useGrpc.ts +4 -2
  7. package/src/index.ts +2 -120
  8. package/src/profile/components/ConsentManager.vue +5 -1
  9. package/src/profile/components/ProfileEditor.vue +1 -1
  10. package/src/profile/components/SecuritySettings.vue +5 -1
  11. package/src/profile/components/SessionList.vue +2 -1
  12. package/src/profile/components/SidRegistrationForm.test.ts +173 -1
  13. package/src/profile/components/SidRegistrationForm.vue +88 -32
  14. package/src/profile/components/index.ts +1 -4
  15. package/src/profile/composables/index.ts +1 -0
  16. package/src/profile/composables/operationPlan.test.ts +155 -0
  17. package/src/profile/composables/operationPlan.ts +218 -0
  18. package/src/profile/composables/useAccountApi.ts +4 -4
  19. package/src/profile/composables/useAuth.test.ts +312 -31
  20. package/src/profile/composables/useAuth.ts +176 -30
  21. package/src/profile/composables/useAuthApi.opaque.test.ts +13 -6
  22. package/src/profile/composables/useAuthApi.ts +40 -18
  23. package/src/profile/composables/useCurrentPasswordRequirement.test.ts +196 -0
  24. package/src/profile/composables/useCurrentPasswordRequirement.ts +95 -0
  25. package/src/profile/composables/useEnrollmentApi.test.ts +7 -3
  26. package/src/profile/composables/useEnrollmentApi.ts +3 -5
  27. package/src/profile/composables/useIdentityApi.ts +4 -4
  28. package/src/profile/composables/useSecurityLevel.test.ts +1 -1
  29. package/src/profile/composables/useSecurityLevel.ts +1 -1
  30. package/src/quasar/SidPasswordCeremony.test.ts +253 -0
  31. package/src/quasar/SidPasswordCeremony.vue +456 -0
  32. package/src/quasar/index.ts +2 -0
  33. package/src/utils/rpcRefusal.test.ts +4 -4
  34. package/src/utils/rpcRefusal.ts +8 -4
  35. package/dist/browser-vp/index.cjs +0 -349
  36. package/dist/browser-vp/index.d.cts +0 -276
  37. package/dist/browser-vp/index.d.ts +0 -276
  38. package/dist/browser-vp/index.js +0 -349
  39. package/dist/chunk-C3Q766CY.cjs +0 -125
  40. package/dist/chunk-J5YXRXB5.js +0 -125
  41. package/dist/index.cjs +0 -23581
  42. package/dist/index.d.cts +0 -11539
  43. package/dist/index.d.ts +0 -11539
  44. package/dist/index.js +0 -23581
  45. package/dist/storage-4CB2JTLW.cjs +0 -22
  46. package/dist/storage-THRWREZP.js +0 -22
  47. package/src/browser-vp/crypto.ts +0 -253
  48. package/src/browser-vp/index.test.ts +0 -10
  49. package/src/browser-vp/index.ts +0 -116
  50. package/src/browser-vp/prf.ts +0 -179
  51. package/src/browser-vp/storage.test.ts +0 -9
  52. package/src/browser-vp/storage.ts +0 -163
  53. package/src/browser-vp/types.ts +0 -95
  54. package/src/browser-vp/vp.test.ts +0 -43
  55. package/src/browser-vp/vp.ts +0 -111
  56. package/src/generated/google/api/http.ts +0 -687
  57. package/src/generated/google/api/httpbody.ts +0 -159
  58. package/src/generated/google/protobuf/any.ts +0 -326
  59. package/src/generated/google/protobuf/descriptor.ts +0 -4865
  60. package/src/generated/google/protobuf/duration.ts +0 -230
  61. package/src/generated/google/protobuf/empty.ts +0 -94
  62. package/src/generated/google/protobuf/struct.ts +0 -484
  63. package/src/generated/google/protobuf/timestamp.ts +0 -287
  64. package/src/generated/google/rpc/error_details.ts +0 -1386
  65. package/src/generated/google/rpc/status.ts +0 -127
  66. package/src/generated/sid/v1/account/account.client.ts +0 -527
  67. package/src/generated/sid/v1/account/account.ts +0 -3261
  68. package/src/generated/sid/v1/admin/admin.client.ts +0 -139
  69. package/src/generated/sid/v1/admin/admin.ts +0 -781
  70. package/src/generated/sid/v1/admin/admin_roles.client.ts +0 -181
  71. package/src/generated/sid/v1/admin/admin_roles.ts +0 -1606
  72. package/src/generated/sid/v1/admin/analytics.client.ts +0 -106
  73. package/src/generated/sid/v1/admin/analytics.ts +0 -1017
  74. package/src/generated/sid/v1/admin/branding.client.ts +0 -166
  75. package/src/generated/sid/v1/admin/branding.ts +0 -1906
  76. package/src/generated/sid/v1/admin/enrollment.client.ts +0 -177
  77. package/src/generated/sid/v1/admin/enrollment.ts +0 -1766
  78. package/src/generated/sid/v1/admin/events.client.ts +0 -195
  79. package/src/generated/sid/v1/admin/events.ts +0 -1900
  80. package/src/generated/sid/v1/admin/flow.client.ts +0 -183
  81. package/src/generated/sid/v1/admin/flow.ts +0 -1552
  82. package/src/generated/sid/v1/admin/keys.client.ts +0 -107
  83. package/src/generated/sid/v1/admin/keys.ts +0 -931
  84. package/src/generated/sid/v1/admin/login_experience.client.ts +0 -160
  85. package/src/generated/sid/v1/admin/login_experience.ts +0 -1061
  86. package/src/generated/sid/v1/admin/migration.client.ts +0 -127
  87. package/src/generated/sid/v1/admin/migration.ts +0 -1948
  88. package/src/generated/sid/v1/admin/notifications.client.ts +0 -134
  89. package/src/generated/sid/v1/admin/notifications.ts +0 -1273
  90. package/src/generated/sid/v1/admin/operations.client.ts +0 -241
  91. package/src/generated/sid/v1/admin/operations.ts +0 -2633
  92. package/src/generated/sid/v1/admin/organizations.client.ts +0 -57
  93. package/src/generated/sid/v1/admin/organizations.ts +0 -332
  94. package/src/generated/sid/v1/admin/provisioning.client.ts +0 -232
  95. package/src/generated/sid/v1/admin/provisioning.ts +0 -1383
  96. package/src/generated/sid/v1/admin/scopes.client.ts +0 -171
  97. package/src/generated/sid/v1/admin/scopes.ts +0 -1324
  98. package/src/generated/sid/v1/admin/security.client.ts +0 -410
  99. package/src/generated/sid/v1/admin/security.ts +0 -4077
  100. package/src/generated/sid/v1/admin/sessions.client.ts +0 -114
  101. package/src/generated/sid/v1/admin/sessions.ts +0 -923
  102. package/src/generated/sid/v1/admin/settings.client.ts +0 -351
  103. package/src/generated/sid/v1/admin/settings.ts +0 -2458
  104. package/src/generated/sid/v1/admin/user_profile.client.ts +0 -57
  105. package/src/generated/sid/v1/admin/user_profile.ts +0 -730
  106. package/src/generated/sid/v1/admin/webhooks.client.ts +0 -269
  107. package/src/generated/sid/v1/admin/webhooks.ts +0 -2804
  108. package/src/generated/sid/v1/attestation/attestation.client.ts +0 -115
  109. package/src/generated/sid/v1/attestation/attestation.ts +0 -813
  110. package/src/generated/sid/v1/authn/auth.client.ts +0 -943
  111. package/src/generated/sid/v1/authn/auth.ts +0 -9719
  112. package/src/generated/sid/v1/authn/flows.client.ts +0 -413
  113. package/src/generated/sid/v1/authn/flows.ts +0 -3205
  114. package/src/generated/sid/v1/authn/issuer.client.ts +0 -489
  115. package/src/generated/sid/v1/authn/issuer.ts +0 -1600
  116. package/src/generated/sid/v1/authn/password_history.client.ts +0 -57
  117. package/src/generated/sid/v1/authn/password_history.ts +0 -578
  118. package/src/generated/sid/v1/authn/system_integration.client.ts +0 -73
  119. package/src/generated/sid/v1/authn/system_integration.ts +0 -218
  120. package/src/generated/sid/v1/authz/authz.client.ts +0 -459
  121. package/src/generated/sid/v1/authz/authz.ts +0 -6524
  122. package/src/generated/sid/v1/authz/forward_auth.client.ts +0 -97
  123. package/src/generated/sid/v1/authz/forward_auth.ts +0 -79
  124. package/src/generated/sid/v1/authz/governance.client.ts +0 -102
  125. package/src/generated/sid/v1/authz/governance.ts +0 -875
  126. package/src/generated/sid/v1/common/errors.ts +0 -710
  127. package/src/generated/sid/v1/common/pagination.ts +0 -152
  128. package/src/generated/sid/v1/common/transport.ts +0 -227
  129. package/src/generated/sid/v1/common/types.ts +0 -151
  130. package/src/generated/sid/v1/events/event_stream.client.ts +0 -67
  131. package/src/generated/sid/v1/events/event_stream.ts +0 -375
  132. package/src/generated/sid/v1/federation/upstream.client.ts +0 -142
  133. package/src/generated/sid/v1/federation/upstream.ts +0 -1736
  134. package/src/generated/sid/v1/identity/identity.client.ts +0 -467
  135. package/src/generated/sid/v1/identity/identity.ts +0 -5676
  136. package/src/generated/sid/v1/ids/ids.ts +0 -395
  137. package/src/generated/sid/v1/machine/machine_user.client.ts +0 -219
  138. package/src/generated/sid/v1/machine/machine_user.ts +0 -2364
  139. package/src/generated/sid/v1/machine/pat.client.ts +0 -130
  140. package/src/generated/sid/v1/machine/pat.ts +0 -1083
  141. package/src/generated/sid/v1/projects/projects.client.ts +0 -404
  142. package/src/generated/sid/v1/projects/projects.ts +0 -4446
  143. package/src/generated/sid/v1/scim/scim.client.ts +0 -520
  144. package/src/generated/sid/v1/scim/scim.ts +0 -3182
  145. package/src/generated/sid/v1/test/test.client.ts +0 -232
  146. package/src/generated/sid/v1/test/test.ts +0 -1136
  147. package/src/transport/adaptive.test.ts +0 -728
  148. package/src/transport/adaptive.ts +0 -308
  149. package/src/transport/framing.test.ts +0 -227
  150. package/src/transport/framing.ts +0 -98
  151. package/src/transport/webtransport.test.ts +0 -728
  152. package/src/transport/webtransport.ts +0 -323
@@ -1,308 +0,0 @@
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
- }
@@ -1,227 +0,0 @@
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
- });
@@ -1,98 +0,0 @@
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
- }