@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,728 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { Deferred, RpcError, UnaryCall } from "@protobuf-ts/runtime-rpc";
3
+ import type {
4
+ RpcTransport,
5
+ MethodInfo,
6
+ RpcOptions,
7
+ RpcMetadata,
8
+ RpcStatus,
9
+ } from "@protobuf-ts/runtime-rpc";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Mock WebTransport global (does not exist in Node.js)
13
+ // ---------------------------------------------------------------------------
14
+
15
+ class MockWebTransport {
16
+ ready = Promise.resolve();
17
+ closed = new Promise<void>(() => {});
18
+ close = vi.fn();
19
+ createBidirectionalStream = vi.fn();
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Mock modules BEFORE importing the module under test
24
+ // ---------------------------------------------------------------------------
25
+
26
+ // Mock GrpcWebFetchTransport
27
+ vi.mock("@protobuf-ts/grpcweb-transport", () => ({
28
+ GrpcWebFetchTransport: vi
29
+ .fn()
30
+ .mockImplementation(() => createMockTransport()),
31
+ }));
32
+
33
+ // Mock WebTransportConnection and WebTransportRpcTransport
34
+ const mockWtConnect = vi.fn<[], Promise<void>>();
35
+ const mockWtClose = vi.fn();
36
+ let mockWtTransportInstance: RpcTransport;
37
+
38
+ vi.mock("./webtransport", () => ({
39
+ WebTransportConnection: vi.fn().mockImplementation(() => ({
40
+ connect: mockWtConnect,
41
+ close: mockWtClose,
42
+ })),
43
+ WebTransportRpcTransport: vi.fn().mockImplementation(() => {
44
+ return mockWtTransportInstance;
45
+ }),
46
+ }));
47
+
48
+ // Now import the module under test (AFTER vi.mock calls)
49
+ import { AdaptiveRpcTransport } from "./adaptive";
50
+ import type { AdaptiveTransportOptions } from "./adaptive";
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Helpers
54
+ // ---------------------------------------------------------------------------
55
+
56
+ interface MockMsg {
57
+ value: string;
58
+ }
59
+
60
+ function createMethodInfo(): MethodInfo<MockMsg, MockMsg> {
61
+ return {
62
+ service: { typeName: "test.TestService", methods: [], options: {} },
63
+ name: "TestMethod",
64
+ localName: "testMethod",
65
+ idempotency: undefined,
66
+ serverStreaming: false,
67
+ clientStreaming: false,
68
+ I: {
69
+ typeName: "TestInput",
70
+ toBinary: () => new Uint8Array(),
71
+ fromBinary: () => ({ value: "input" }),
72
+ },
73
+ O: {
74
+ typeName: "TestOutput",
75
+ toBinary: () => new Uint8Array(),
76
+ fromBinary: () => ({ value: "output" }),
77
+ },
78
+ options: {},
79
+ } as unknown as MethodInfo<MockMsg, MockMsg>;
80
+ }
81
+
82
+ function createMockTransport(): RpcTransport {
83
+ return {
84
+ mergeOptions: (opts?: Partial<RpcOptions>) => opts ?? {},
85
+ unary: vi.fn(),
86
+ serverStreaming: vi.fn(),
87
+ clientStreaming: vi.fn(),
88
+ duplex: vi.fn(),
89
+ } as unknown as RpcTransport;
90
+ }
91
+
92
+ /** Create a resolved UnaryCall with the given response. */
93
+ function resolvedUnaryCall<I extends object, O extends object>(
94
+ method: MethodInfo<I, O>,
95
+ input: I,
96
+ response: O,
97
+ ): UnaryCall<I, O> {
98
+ const dH = new Deferred<RpcMetadata>();
99
+ const dM = new Deferred<O>();
100
+ const dS = new Deferred<RpcStatus>();
101
+ const dT = new Deferred<RpcMetadata>();
102
+
103
+ dH.resolve({});
104
+ dM.resolve(response);
105
+ dS.resolve({ code: "OK", detail: "" });
106
+ dT.resolve({});
107
+
108
+ return new UnaryCall(
109
+ method,
110
+ {},
111
+ input,
112
+ dH.promise,
113
+ dM.promise,
114
+ dS.promise,
115
+ dT.promise,
116
+ );
117
+ }
118
+
119
+ /** Create a rejected UnaryCall that fails with the given RpcError. */
120
+ function rejectedUnaryCall<I extends object, O extends object>(
121
+ method: MethodInfo<I, O>,
122
+ input: I,
123
+ error: RpcError,
124
+ ): UnaryCall<I, O> {
125
+ const dH = new Deferred<RpcMetadata>();
126
+ const dM = new Deferred<O>();
127
+ const dS = new Deferred<RpcStatus>();
128
+ const dT = new Deferred<RpcMetadata>();
129
+
130
+ dH.reject(error);
131
+ dM.reject(error);
132
+ dS.reject(error);
133
+ dT.reject(error);
134
+
135
+ return new UnaryCall(
136
+ method,
137
+ {},
138
+ input,
139
+ dH.promise,
140
+ dM.promise,
141
+ dS.promise,
142
+ dT.promise,
143
+ );
144
+ }
145
+
146
+ function getGrpcWebTransport(adaptive: AdaptiveRpcTransport): RpcTransport {
147
+ // Access the private fallback field
148
+ return (adaptive as unknown as { fallback: RpcTransport }).fallback;
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Tests
153
+ // ---------------------------------------------------------------------------
154
+
155
+ describe("AdaptiveRpcTransport", () => {
156
+ const method = createMethodInfo();
157
+ const input: MockMsg = { value: "hello" };
158
+ const options: RpcOptions = { meta: {} };
159
+
160
+ beforeEach(() => {
161
+ vi.useFakeTimers();
162
+ vi.clearAllMocks();
163
+ mockWtConnect.mockResolvedValue(undefined);
164
+ mockWtTransportInstance = createMockTransport();
165
+ });
166
+
167
+ afterEach(() => {
168
+ vi.useRealTimers();
169
+ });
170
+
171
+ describe("when WebTransport is unavailable (no URL)", () => {
172
+ it("always uses gRPC-web", async () => {
173
+ const opts: AdaptiveTransportOptions = {
174
+ grpcWebUrl: "https://grpc.sid.example.com",
175
+ // no webTransportUrl
176
+ };
177
+
178
+ const adaptive = new AdaptiveRpcTransport(opts);
179
+ await adaptive.init();
180
+
181
+ expect(adaptive.activeTransport).toBe("grpc-web");
182
+
183
+ const fallback = getGrpcWebTransport(adaptive);
184
+ const mockCall = resolvedUnaryCall(method, input, { value: "ok" });
185
+ (fallback.unary as ReturnType<typeof vi.fn>).mockReturnValue(mockCall);
186
+
187
+ adaptive.unary(method, input, options);
188
+
189
+ expect(fallback.unary).toHaveBeenCalledWith(method, input, options);
190
+ });
191
+ });
192
+
193
+ describe("when WebTransport is available", () => {
194
+ let adaptive: AdaptiveRpcTransport;
195
+
196
+ beforeEach(async () => {
197
+ // Make WebTransport global exist so the constructor enables WT
198
+ (globalThis as Record<string, unknown>).WebTransport = MockWebTransport;
199
+
200
+ const opts: AdaptiveTransportOptions = {
201
+ grpcWebUrl: "https://grpc.sid.example.com",
202
+ webTransportUrl: "https://wt.sid.example.com",
203
+ reconnectDelay: 1000,
204
+ };
205
+
206
+ adaptive = new AdaptiveRpcTransport(opts);
207
+ await adaptive.init();
208
+ });
209
+
210
+ afterEach(() => {
211
+ adaptive.close();
212
+ delete (globalThis as Record<string, unknown>).WebTransport;
213
+ });
214
+
215
+ it("uses WebTransport as primary transport", () => {
216
+ expect(adaptive.activeTransport).toBe("webtransport");
217
+ });
218
+
219
+ it("sends unary calls through WebTransport primary", async () => {
220
+ const mockCall = resolvedUnaryCall(method, input, { value: "wt-ok" });
221
+ (
222
+ mockWtTransportInstance.unary as ReturnType<typeof vi.fn>
223
+ ).mockReturnValue(mockCall);
224
+
225
+ const call = adaptive.unary(method, input, options);
226
+ const response = await call.response;
227
+
228
+ expect(response).toEqual({ value: "wt-ok" });
229
+ expect(mockWtTransportInstance.unary).toHaveBeenCalledWith(
230
+ method,
231
+ input,
232
+ options,
233
+ );
234
+ });
235
+
236
+ it("fails over to gRPC-web on transport error (UNAVAILABLE)", async () => {
237
+ // Primary rejects with UNAVAILABLE (transport error)
238
+ const transportErr = new RpcError("connection lost", "UNAVAILABLE");
239
+ const failedCall = rejectedUnaryCall(method, input, transportErr);
240
+ (
241
+ mockWtTransportInstance.unary as ReturnType<typeof vi.fn>
242
+ ).mockReturnValue(failedCall);
243
+
244
+ // Fallback succeeds
245
+ const fallback = getGrpcWebTransport(adaptive);
246
+ const successCall = resolvedUnaryCall(method, input, {
247
+ value: "fallback-ok",
248
+ });
249
+ (fallback.unary as ReturnType<typeof vi.fn>).mockReturnValue(successCall);
250
+
251
+ const call = adaptive.unary(method, input, options);
252
+ const response = await call.response;
253
+
254
+ expect(response).toEqual({ value: "fallback-ok" });
255
+ expect(fallback.unary).toHaveBeenCalledWith(method, input, options);
256
+ });
257
+
258
+ it("does NOT failover on application error (NOT_FOUND)", async () => {
259
+ const appErr = new RpcError("not found", "NOT_FOUND");
260
+ const failedCall = rejectedUnaryCall(method, input, appErr);
261
+ (
262
+ mockWtTransportInstance.unary as ReturnType<typeof vi.fn>
263
+ ).mockReturnValue(failedCall);
264
+
265
+ const fallback = getGrpcWebTransport(adaptive);
266
+
267
+ const call = adaptive.unary(method, input, options);
268
+
269
+ await expect(call.response).rejects.toThrow("not found");
270
+ expect(fallback.unary).not.toHaveBeenCalled();
271
+ });
272
+
273
+ it("does NOT failover on PERMISSION_DENIED", async () => {
274
+ const appErr = new RpcError("forbidden", "PERMISSION_DENIED");
275
+ const failedCall = rejectedUnaryCall(method, input, appErr);
276
+ (
277
+ mockWtTransportInstance.unary as ReturnType<typeof vi.fn>
278
+ ).mockReturnValue(failedCall);
279
+
280
+ const fallback = getGrpcWebTransport(adaptive);
281
+
282
+ const call = adaptive.unary(method, input, options);
283
+
284
+ await expect(call.response).rejects.toThrow("forbidden");
285
+ expect(fallback.unary).not.toHaveBeenCalled();
286
+ });
287
+
288
+ it("schedules reconnect after failover", async () => {
289
+ const transportErr = new RpcError("connection lost", "UNAVAILABLE");
290
+ const failedCall = rejectedUnaryCall(method, input, transportErr);
291
+ (
292
+ mockWtTransportInstance.unary as ReturnType<typeof vi.fn>
293
+ ).mockReturnValue(failedCall);
294
+
295
+ const fallback = getGrpcWebTransport(adaptive);
296
+ const successCall = resolvedUnaryCall(method, input, { value: "ok" });
297
+ (fallback.unary as ReturnType<typeof vi.fn>).mockReturnValue(successCall);
298
+
299
+ const call = adaptive.unary(method, input, options);
300
+ await call.response;
301
+
302
+ // After failover, transport should be grpc-web
303
+ expect(adaptive.activeTransport).toBe("grpc-web");
304
+
305
+ // Reconnect is scheduled — advance timer
306
+ mockWtConnect.mockResolvedValue(undefined);
307
+ await vi.advanceTimersByTimeAsync(1000);
308
+
309
+ // After successful reconnect, primary should be available again
310
+ expect(adaptive.activeTransport).toBe("webtransport");
311
+ });
312
+
313
+ it("retries reconnect if reconnect attempt fails", async () => {
314
+ const transportErr = new RpcError("connection lost", "UNAVAILABLE");
315
+ const failedCall = rejectedUnaryCall(method, input, transportErr);
316
+ (
317
+ mockWtTransportInstance.unary as ReturnType<typeof vi.fn>
318
+ ).mockReturnValue(failedCall);
319
+
320
+ const fallback = getGrpcWebTransport(adaptive);
321
+ const successCall = resolvedUnaryCall(method, input, { value: "ok" });
322
+ (fallback.unary as ReturnType<typeof vi.fn>).mockReturnValue(successCall);
323
+
324
+ const call = adaptive.unary(method, input, options);
325
+ await call.response;
326
+
327
+ // First reconnect attempt fails
328
+ mockWtConnect.mockRejectedValueOnce(new Error("still down"));
329
+ await vi.advanceTimersByTimeAsync(1000);
330
+
331
+ // Still on gRPC-web
332
+ expect(adaptive.activeTransport).toBe("grpc-web");
333
+
334
+ // Second reconnect succeeds
335
+ mockWtConnect.mockResolvedValue(undefined);
336
+ await vi.advanceTimersByTimeAsync(1000);
337
+
338
+ expect(adaptive.activeTransport).toBe("webtransport");
339
+ });
340
+ });
341
+
342
+ describe("activeTransport getter", () => {
343
+ it("returns grpc-web when WebTransport URL not provided", async () => {
344
+ const adaptive = new AdaptiveRpcTransport({
345
+ grpcWebUrl: "https://grpc.sid.example.com",
346
+ });
347
+ await adaptive.init();
348
+
349
+ expect(adaptive.activeTransport).toBe("grpc-web");
350
+ });
351
+
352
+ it("returns webtransport after successful init", async () => {
353
+ (globalThis as Record<string, unknown>).WebTransport = MockWebTransport;
354
+
355
+ const adaptive = new AdaptiveRpcTransport({
356
+ grpcWebUrl: "https://grpc.sid.example.com",
357
+ webTransportUrl: "https://wt.sid.example.com",
358
+ });
359
+ await adaptive.init();
360
+
361
+ expect(adaptive.activeTransport).toBe("webtransport");
362
+
363
+ adaptive.close();
364
+ delete (globalThis as Record<string, unknown>).WebTransport;
365
+ });
366
+
367
+ it("returns grpc-web after init failure", async () => {
368
+ (globalThis as Record<string, unknown>).WebTransport = MockWebTransport;
369
+ mockWtConnect.mockRejectedValueOnce(new Error("connect failed"));
370
+
371
+ const adaptive = new AdaptiveRpcTransport({
372
+ grpcWebUrl: "https://grpc.sid.example.com",
373
+ webTransportUrl: "https://wt.sid.example.com",
374
+ });
375
+ await adaptive.init();
376
+
377
+ expect(adaptive.activeTransport).toBe("grpc-web");
378
+
379
+ adaptive.close();
380
+ delete (globalThis as Record<string, unknown>).WebTransport;
381
+ });
382
+
383
+ it("schedules reconnect after init() failure", async () => {
384
+ (globalThis as Record<string, unknown>).WebTransport = MockWebTransport;
385
+ mockWtConnect.mockRejectedValueOnce(new Error("connect failed"));
386
+
387
+ const adaptive = new AdaptiveRpcTransport({
388
+ grpcWebUrl: "https://grpc.sid.example.com",
389
+ webTransportUrl: "https://wt.sid.example.com",
390
+ reconnectDelay: 2000,
391
+ });
392
+ await adaptive.init();
393
+
394
+ expect(adaptive.activeTransport).toBe("grpc-web");
395
+ mockWtConnect.mockResolvedValue(undefined);
396
+
397
+ // Reconnect fires after delay
398
+ await vi.advanceTimersByTimeAsync(2000);
399
+ expect(mockWtConnect).toHaveBeenCalledTimes(2); // init fail + reconnect
400
+ expect(adaptive.activeTransport).toBe("webtransport");
401
+
402
+ adaptive.close();
403
+ delete (globalThis as Record<string, unknown>).WebTransport;
404
+ });
405
+ });
406
+
407
+ describe("onTransportChange callback", () => {
408
+ it("fires when transport changes during failover", async () => {
409
+ (globalThis as Record<string, unknown>).WebTransport = MockWebTransport;
410
+
411
+ const onChange = vi.fn();
412
+ const adaptive = new AdaptiveRpcTransport({
413
+ grpcWebUrl: "https://grpc.sid.example.com",
414
+ webTransportUrl: "https://wt.sid.example.com",
415
+ reconnectDelay: 1000,
416
+ onTransportChange: onChange,
417
+ });
418
+ await adaptive.init();
419
+
420
+ // init() calls onTransportChange with "webtransport"
421
+ expect(onChange).toHaveBeenCalledWith("webtransport");
422
+
423
+ // Trigger failover
424
+ const transportErr = new RpcError("down", "UNAVAILABLE");
425
+ const failedCall = rejectedUnaryCall(method, input, transportErr);
426
+ (
427
+ mockWtTransportInstance.unary as ReturnType<typeof vi.fn>
428
+ ).mockReturnValue(failedCall);
429
+
430
+ const fallback = getGrpcWebTransport(adaptive);
431
+ const successCall = resolvedUnaryCall(method, input, { value: "ok" });
432
+ (fallback.unary as ReturnType<typeof vi.fn>).mockReturnValue(successCall);
433
+
434
+ const call = adaptive.unary(method, input, options);
435
+ await call.response;
436
+
437
+ expect(onChange).toHaveBeenCalledWith("grpc-web");
438
+
439
+ adaptive.close();
440
+ delete (globalThis as Record<string, unknown>).WebTransport;
441
+ });
442
+ });
443
+
444
+ describe("close()", () => {
445
+ it("cancels pending reconnect timer", async () => {
446
+ (globalThis as Record<string, unknown>).WebTransport = MockWebTransport;
447
+
448
+ const adaptive = new AdaptiveRpcTransport({
449
+ grpcWebUrl: "https://grpc.sid.example.com",
450
+ webTransportUrl: "https://wt.sid.example.com",
451
+ reconnectDelay: 5000,
452
+ });
453
+ await adaptive.init();
454
+
455
+ // Trigger failover to schedule reconnect
456
+ const transportErr = new RpcError("down", "UNAVAILABLE");
457
+ const failedCall = rejectedUnaryCall(method, input, transportErr);
458
+ (
459
+ mockWtTransportInstance.unary as ReturnType<typeof vi.fn>
460
+ ).mockReturnValue(failedCall);
461
+
462
+ const fallback = getGrpcWebTransport(adaptive);
463
+ const successCall = resolvedUnaryCall(method, input, { value: "ok" });
464
+ (fallback.unary as ReturnType<typeof vi.fn>).mockReturnValue(successCall);
465
+
466
+ const call = adaptive.unary(method, input, options);
467
+ await call.response;
468
+
469
+ // Close before reconnect fires
470
+ adaptive.close();
471
+
472
+ // Advance past reconnect delay — should NOT attempt reconnect
473
+ mockWtConnect.mockClear();
474
+ await vi.advanceTimersByTimeAsync(10000);
475
+
476
+ // connect should not be called again (only the init() call)
477
+ expect(mockWtConnect).not.toHaveBeenCalled();
478
+
479
+ delete (globalThis as Record<string, unknown>).WebTransport;
480
+ });
481
+ });
482
+
483
+ describe("clientStreaming / duplex", () => {
484
+ it("clientStreaming throws RpcError", () => {
485
+ const adaptive = new AdaptiveRpcTransport({
486
+ grpcWebUrl: "https://grpc.sid.example.com",
487
+ });
488
+ expect(() => adaptive.clientStreaming(method, options)).toThrow(RpcError);
489
+ });
490
+
491
+ it("duplex throws RpcError", () => {
492
+ const adaptive = new AdaptiveRpcTransport({
493
+ grpcWebUrl: "https://grpc.sid.example.com",
494
+ });
495
+ expect(() => adaptive.duplex(method, options)).toThrow(RpcError);
496
+ });
497
+ });
498
+
499
+ describe("failover triggers (all transport error codes)", () => {
500
+ it("fails over on CANCELLED", async () => {
501
+ (globalThis as Record<string, unknown>).WebTransport = MockWebTransport;
502
+ const adaptive = new AdaptiveRpcTransport({
503
+ grpcWebUrl: "https://grpc.sid.example.com",
504
+ webTransportUrl: "https://wt.sid.example.com",
505
+ });
506
+ await adaptive.init();
507
+
508
+ const err = new RpcError("cancelled", "CANCELLED");
509
+ (
510
+ mockWtTransportInstance.unary as ReturnType<typeof vi.fn>
511
+ ).mockReturnValue(rejectedUnaryCall(method, input, err));
512
+
513
+ const fallback = getGrpcWebTransport(adaptive);
514
+ (fallback.unary as ReturnType<typeof vi.fn>).mockReturnValue(
515
+ resolvedUnaryCall(method, input, { value: "ok" }),
516
+ );
517
+
518
+ await adaptive.unary(method, input, options).response;
519
+ expect(adaptive.activeTransport).toBe("grpc-web");
520
+
521
+ adaptive.close();
522
+ delete (globalThis as Record<string, unknown>).WebTransport;
523
+ });
524
+
525
+ it("fails over on UNKNOWN", async () => {
526
+ (globalThis as Record<string, unknown>).WebTransport = MockWebTransport;
527
+ const adaptive = new AdaptiveRpcTransport({
528
+ grpcWebUrl: "https://grpc.sid.example.com",
529
+ webTransportUrl: "https://wt.sid.example.com",
530
+ });
531
+ await adaptive.init();
532
+
533
+ const err = new RpcError("unknown", "UNKNOWN");
534
+ (
535
+ mockWtTransportInstance.unary as ReturnType<typeof vi.fn>
536
+ ).mockReturnValue(rejectedUnaryCall(method, input, err));
537
+
538
+ const fallback = getGrpcWebTransport(adaptive);
539
+ (fallback.unary as ReturnType<typeof vi.fn>).mockReturnValue(
540
+ resolvedUnaryCall(method, input, { value: "ok" }),
541
+ );
542
+
543
+ await adaptive.unary(method, input, options).response;
544
+ expect(adaptive.activeTransport).toBe("grpc-web");
545
+
546
+ adaptive.close();
547
+ delete (globalThis as Record<string, unknown>).WebTransport;
548
+ });
549
+
550
+ it("fails over on non-RpcError (plain Error from stream failure)", async () => {
551
+ (globalThis as Record<string, unknown>).WebTransport = MockWebTransport;
552
+ const adaptive = new AdaptiveRpcTransport({
553
+ grpcWebUrl: "https://grpc.sid.example.com",
554
+ webTransportUrl: "https://wt.sid.example.com",
555
+ });
556
+ await adaptive.init();
557
+
558
+ // Simulate a plain Error (not RpcError) from the primary transport
559
+ // This happens when WebTransport stream throws a non-gRPC error
560
+ const defH = new Deferred<RpcMetadata>();
561
+ const defM = new Deferred<MockMsg>();
562
+ const defS = new Deferred<RpcStatus>();
563
+ const defT = new Deferred<RpcMetadata>();
564
+ const plainErr = new Error("stream aborted");
565
+ defH.reject(plainErr);
566
+ defM.reject(plainErr);
567
+ defS.reject(plainErr);
568
+ defT.reject(plainErr);
569
+ const crashedCall = new UnaryCall(
570
+ method,
571
+ {},
572
+ input,
573
+ defH.promise,
574
+ defM.promise,
575
+ defS.promise,
576
+ defT.promise,
577
+ );
578
+ (
579
+ mockWtTransportInstance.unary as ReturnType<typeof vi.fn>
580
+ ).mockReturnValue(crashedCall);
581
+
582
+ const fallback = getGrpcWebTransport(adaptive);
583
+ (fallback.unary as ReturnType<typeof vi.fn>).mockReturnValue(
584
+ resolvedUnaryCall(method, input, { value: "fallback-ok" }),
585
+ );
586
+
587
+ const response = await adaptive.unary(method, input, options).response;
588
+ expect(response).toEqual({ value: "fallback-ok" });
589
+ expect(adaptive.activeTransport).toBe("grpc-web");
590
+
591
+ adaptive.close();
592
+ delete (globalThis as Record<string, unknown>).WebTransport;
593
+ });
594
+
595
+ it("propagates error when fallback also fails", async () => {
596
+ (globalThis as Record<string, unknown>).WebTransport = MockWebTransport;
597
+ const adaptive = new AdaptiveRpcTransport({
598
+ grpcWebUrl: "https://grpc.sid.example.com",
599
+ webTransportUrl: "https://wt.sid.example.com",
600
+ });
601
+ await adaptive.init();
602
+
603
+ // Primary fails with transport error
604
+ const transportErr = new RpcError("primary down", "UNAVAILABLE");
605
+ (
606
+ mockWtTransportInstance.unary as ReturnType<typeof vi.fn>
607
+ ).mockReturnValue(rejectedUnaryCall(method, input, transportErr));
608
+
609
+ // Fallback also fails
610
+ const fallbackErr = new RpcError("fallback down", "UNAVAILABLE");
611
+ const fallback = getGrpcWebTransport(adaptive);
612
+ (fallback.unary as ReturnType<typeof vi.fn>).mockReturnValue(
613
+ rejectedUnaryCall(method, input, fallbackErr),
614
+ );
615
+
616
+ const err = await adaptive
617
+ .unary(method, input, options)
618
+ .response.catch((e: unknown) => e);
619
+ expect((err as RpcError).message).toBe("fallback down");
620
+
621
+ adaptive.close();
622
+ delete (globalThis as Record<string, unknown>).WebTransport;
623
+ });
624
+ });
625
+
626
+ describe("scheduleReconnect idempotency", () => {
627
+ it("does not schedule multiple reconnect timers on concurrent failovers", async () => {
628
+ (globalThis as Record<string, unknown>).WebTransport = MockWebTransport;
629
+ const adaptive = new AdaptiveRpcTransport({
630
+ grpcWebUrl: "https://grpc.sid.example.com",
631
+ webTransportUrl: "https://wt.sid.example.com",
632
+ reconnectDelay: 5000,
633
+ });
634
+ await adaptive.init();
635
+
636
+ const transportErr = new RpcError("down", "UNAVAILABLE");
637
+ const fallback = getGrpcWebTransport(adaptive);
638
+
639
+ // Two concurrent unary calls both fail with UNAVAILABLE
640
+ (
641
+ mockWtTransportInstance.unary as ReturnType<typeof vi.fn>
642
+ ).mockReturnValue(rejectedUnaryCall(method, input, transportErr));
643
+ (fallback.unary as ReturnType<typeof vi.fn>).mockReturnValue(
644
+ resolvedUnaryCall(method, input, { value: "ok" }),
645
+ );
646
+
647
+ await Promise.all([
648
+ adaptive.unary(method, input, options).response,
649
+ adaptive.unary(method, input, options).response,
650
+ ]);
651
+
652
+ // Advance past the reconnect delay — connect should fire exactly once
653
+ mockWtConnect.mockResolvedValue(undefined);
654
+ await vi.advanceTimersByTimeAsync(5000);
655
+
656
+ // Only one reconnect attempt, not two (init=1, reconnect=1 — second failover is no-op)
657
+ expect(mockWtConnect).toHaveBeenCalledTimes(2);
658
+
659
+ adaptive.close();
660
+ delete (globalThis as Record<string, unknown>).WebTransport;
661
+ });
662
+ });
663
+
664
+ describe("onTransportChange on reconnect", () => {
665
+ it("fires 'webtransport' when reconnect restores primary", async () => {
666
+ (globalThis as Record<string, unknown>).WebTransport = MockWebTransport;
667
+
668
+ const onChange = vi.fn();
669
+ const adaptive = new AdaptiveRpcTransport({
670
+ grpcWebUrl: "https://grpc.sid.example.com",
671
+ webTransportUrl: "https://wt.sid.example.com",
672
+ reconnectDelay: 1000,
673
+ onTransportChange: onChange,
674
+ });
675
+ await adaptive.init();
676
+
677
+ // Trigger failover
678
+ const transportErr = new RpcError("down", "UNAVAILABLE");
679
+ (
680
+ mockWtTransportInstance.unary as ReturnType<typeof vi.fn>
681
+ ).mockReturnValue(rejectedUnaryCall(method, input, transportErr));
682
+ const fallback = getGrpcWebTransport(adaptive);
683
+ (fallback.unary as ReturnType<typeof vi.fn>).mockReturnValue(
684
+ resolvedUnaryCall(method, input, { value: "ok" }),
685
+ );
686
+
687
+ await adaptive.unary(method, input, options).response;
688
+ expect(onChange).toHaveBeenLastCalledWith("grpc-web");
689
+
690
+ // Reconnect
691
+ mockWtConnect.mockResolvedValue(undefined);
692
+ await vi.advanceTimersByTimeAsync(1000);
693
+ expect(onChange).toHaveBeenLastCalledWith("webtransport");
694
+
695
+ adaptive.close();
696
+ delete (globalThis as Record<string, unknown>).WebTransport;
697
+ });
698
+ });
699
+
700
+ describe("serverStreaming", () => {
701
+ it("always routes to gRPC-web fallback (WebTransport does not support streaming)", async () => {
702
+ (globalThis as Record<string, unknown>).WebTransport = MockWebTransport;
703
+ const adaptive = new AdaptiveRpcTransport({
704
+ grpcWebUrl: "https://grpc.sid.example.com",
705
+ webTransportUrl: "https://wt.sid.example.com",
706
+ });
707
+ await adaptive.init();
708
+
709
+ const fallback = getGrpcWebTransport(adaptive);
710
+ const mockStream = {} as ReturnType<RpcTransport["serverStreaming"]>;
711
+ (fallback.serverStreaming as ReturnType<typeof vi.fn>).mockReturnValue(
712
+ mockStream,
713
+ );
714
+
715
+ const result = adaptive.serverStreaming(method, input, options);
716
+
717
+ expect(fallback.serverStreaming).toHaveBeenCalledWith(
718
+ method,
719
+ input,
720
+ options,
721
+ );
722
+ expect(result).toBe(mockStream);
723
+
724
+ adaptive.close();
725
+ delete (globalThis as Record<string, unknown>).WebTransport;
726
+ });
727
+ });
728
+ });