@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.
- package/LICENSE +661 -0
- package/README.md +47 -0
- package/dist/browser-vp/index.cjs +349 -0
- package/dist/browser-vp/index.d.cts +287 -0
- package/dist/browser-vp/index.d.ts +287 -0
- package/dist/browser-vp/index.js +349 -0
- package/dist/chunk-C3Q766CY.cjs +125 -0
- package/dist/chunk-J5YXRXB5.js +125 -0
- package/dist/index.cjs +19360 -0
- package/dist/index.d.cts +9536 -0
- package/dist/index.d.ts +9536 -0
- package/dist/index.js +19360 -0
- package/dist/storage-4CB2JTLW.cjs +22 -0
- package/dist/storage-THRWREZP.js +22 -0
- package/package.json +93 -0
- package/src/browser-vp/crypto.ts +254 -0
- package/src/browser-vp/index.test.ts +10 -0
- package/src/browser-vp/index.ts +118 -0
- package/src/browser-vp/prf.ts +181 -0
- package/src/browser-vp/storage.test.ts +9 -0
- package/src/browser-vp/storage.ts +165 -0
- package/src/browser-vp/types.ts +97 -0
- package/src/browser-vp/vp.test.ts +43 -0
- package/src/browser-vp/vp.ts +113 -0
- package/src/components/SidAvatar.vue +106 -0
- package/src/components/SidConfirmDialog.vue +125 -0
- package/src/components/SidStatusBadge.vue +71 -0
- package/src/components/index.ts +5 -0
- package/src/composables/index.ts +10 -0
- package/src/composables/useAuth.ts +40 -0
- package/src/composables/useBffAuth.ts +147 -0
- package/src/composables/useGrpc.test.ts +196 -0
- package/src/composables/useGrpc.ts +120 -0
- package/src/composables/useSession.ts +80 -0
- package/src/generated/google/api/http.ts +687 -0
- package/src/generated/google/protobuf/descriptor.ts +4865 -0
- package/src/generated/google/protobuf/duration.ts +230 -0
- package/src/generated/google/protobuf/empty.ts +94 -0
- package/src/generated/google/protobuf/struct.ts +484 -0
- package/src/generated/google/protobuf/timestamp.ts +287 -0
- package/src/generated/sid/v1/account/account.client.ts +527 -0
- package/src/generated/sid/v1/account/account.ts +3261 -0
- package/src/generated/sid/v1/admin/admin.client.ts +136 -0
- package/src/generated/sid/v1/admin/admin.ts +846 -0
- package/src/generated/sid/v1/admin/admin_roles.client.ts +181 -0
- package/src/generated/sid/v1/admin/admin_roles.ts +1606 -0
- package/src/generated/sid/v1/admin/analytics.client.ts +106 -0
- package/src/generated/sid/v1/admin/analytics.ts +1017 -0
- package/src/generated/sid/v1/admin/branding.client.ts +166 -0
- package/src/generated/sid/v1/admin/branding.ts +1906 -0
- package/src/generated/sid/v1/admin/enrollment.client.ts +165 -0
- package/src/generated/sid/v1/admin/enrollment.ts +1704 -0
- package/src/generated/sid/v1/admin/events.client.ts +195 -0
- package/src/generated/sid/v1/admin/events.ts +1900 -0
- package/src/generated/sid/v1/admin/flow.client.ts +183 -0
- package/src/generated/sid/v1/admin/flow.ts +1552 -0
- package/src/generated/sid/v1/admin/keys.client.ts +107 -0
- package/src/generated/sid/v1/admin/keys.ts +931 -0
- package/src/generated/sid/v1/admin/login_experience.client.ts +160 -0
- package/src/generated/sid/v1/admin/login_experience.ts +1061 -0
- package/src/generated/sid/v1/admin/migration.client.ts +127 -0
- package/src/generated/sid/v1/admin/migration.ts +1948 -0
- package/src/generated/sid/v1/admin/notifications.client.ts +128 -0
- package/src/generated/sid/v1/admin/notifications.ts +1332 -0
- package/src/generated/sid/v1/admin/operations.client.ts +241 -0
- package/src/generated/sid/v1/admin/operations.ts +2648 -0
- package/src/generated/sid/v1/admin/organizations.client.ts +57 -0
- package/src/generated/sid/v1/admin/organizations.ts +349 -0
- package/src/generated/sid/v1/admin/scopes.client.ts +171 -0
- package/src/generated/sid/v1/admin/scopes.ts +1324 -0
- package/src/generated/sid/v1/admin/security.client.ts +412 -0
- package/src/generated/sid/v1/admin/security.ts +4086 -0
- package/src/generated/sid/v1/admin/sessions.client.ts +114 -0
- package/src/generated/sid/v1/admin/sessions.ts +923 -0
- package/src/generated/sid/v1/admin/settings.client.ts +340 -0
- package/src/generated/sid/v1/admin/settings.ts +2395 -0
- package/src/generated/sid/v1/admin/user_profile.client.ts +57 -0
- package/src/generated/sid/v1/admin/user_profile.ts +730 -0
- package/src/generated/sid/v1/admin/webhooks.client.ts +269 -0
- package/src/generated/sid/v1/admin/webhooks.ts +2804 -0
- package/src/generated/sid/v1/attestation/attestation.client.ts +115 -0
- package/src/generated/sid/v1/attestation/attestation.ts +813 -0
- package/src/generated/sid/v1/authn/auth.client.ts +900 -0
- package/src/generated/sid/v1/authn/auth.ts +9287 -0
- package/src/generated/sid/v1/authn/flows.client.ts +413 -0
- package/src/generated/sid/v1/authn/flows.ts +3217 -0
- package/src/generated/sid/v1/authz/authz.client.ts +459 -0
- package/src/generated/sid/v1/authz/authz.ts +5343 -0
- package/src/generated/sid/v1/authz/governance.client.ts +102 -0
- package/src/generated/sid/v1/authz/governance.ts +818 -0
- package/src/generated/sid/v1/common/errors.ts +184 -0
- package/src/generated/sid/v1/common/pagination.ts +152 -0
- package/src/generated/sid/v1/common/transport.ts +227 -0
- package/src/generated/sid/v1/common/types.ts +151 -0
- package/src/generated/sid/v1/events/event_stream.client.ts +67 -0
- package/src/generated/sid/v1/events/event_stream.ts +374 -0
- package/src/generated/sid/v1/federation/upstream.client.ts +142 -0
- package/src/generated/sid/v1/federation/upstream.ts +1736 -0
- package/src/generated/sid/v1/identity/identity.client.ts +467 -0
- package/src/generated/sid/v1/identity/identity.ts +5613 -0
- package/src/generated/sid/v1/machine/machine_user.client.ts +219 -0
- package/src/generated/sid/v1/machine/machine_user.ts +2400 -0
- package/src/generated/sid/v1/machine/pat.client.ts +147 -0
- package/src/generated/sid/v1/machine/pat.ts +1268 -0
- package/src/generated/sid/v1/projects/projects.client.ts +286 -0
- package/src/generated/sid/v1/projects/projects.ts +2714 -0
- package/src/generated/sid/v1/scim/scim.client.ts +274 -0
- package/src/generated/sid/v1/scim/scim.ts +3043 -0
- package/src/generated/sid/v1/test/test.client.ts +232 -0
- package/src/generated/sid/v1/test/test.ts +1136 -0
- package/src/i18n/index.ts +40 -0
- package/src/index.ts +157 -0
- package/src/profile/auth.ts +15 -0
- package/src/profile/components/ConsentManager.vue +88 -0
- package/src/profile/components/ProfileEditor.vue +146 -0
- package/src/profile/components/SecuritySettings.vue +170 -0
- package/src/profile/components/SessionList.vue +100 -0
- package/src/profile/components/SidLoginForm.test.ts +80 -0
- package/src/profile/components/SidLoginForm.vue +230 -0
- package/src/profile/components/SidMfaChallenge.test.ts +127 -0
- package/src/profile/components/SidMfaChallenge.vue +243 -0
- package/src/profile/components/SidRegistrationForm.test.ts +79 -0
- package/src/profile/components/SidRegistrationForm.vue +317 -0
- package/src/profile/components/index.ts +11 -0
- package/src/profile/composables/index.ts +6 -0
- package/src/profile/composables/useAccountApi.ts +203 -0
- package/src/profile/composables/useAuthApi.opaque.test.ts +140 -0
- package/src/profile/composables/useAuthApi.ts +617 -0
- package/src/profile/composables/useIdentityApi.ts +123 -0
- package/src/profile/composables/useLoginSuccess.test.ts +112 -0
- package/src/profile/composables/useLoginSuccess.ts +65 -0
- package/src/profile/composables/useOpaqueAuth.test.ts +290 -0
- package/src/profile/composables/useOpaqueAuth.ts +191 -0
- package/src/profile/composables/useSecurityLevel.test.ts +198 -0
- package/src/profile/composables/useSecurityLevel.ts +324 -0
- package/src/profile/index.ts +18 -0
- package/src/quasar/SidPrincipalInput.vue +209 -0
- package/src/quasar/index.ts +8 -0
- package/src/stores/auth.test.ts +83 -0
- package/src/stores/auth.ts +92 -0
- package/src/stores/index.ts +1 -0
- package/src/transport/adaptive.test.ts +728 -0
- package/src/transport/adaptive.ts +308 -0
- package/src/transport/framing.test.ts +227 -0
- package/src/transport/framing.ts +98 -0
- package/src/transport/webtransport.test.ts +728 -0
- package/src/transport/webtransport.ts +323 -0
- package/src/types/auth.ts +58 -0
- package/src/types/index.ts +1 -0
- package/src/utils/formatters.test.ts +26 -0
- package/src/utils/formatters.ts +69 -0
- package/src/utils/index.ts +16 -0
- package/src/utils/normalizePrincipal.test.ts +188 -0
- package/src/utils/normalizePrincipal.ts +159 -0
- package/src/utils/validators.test.ts +75 -0
- 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
|
+
}
|