@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 { RpcError } from "@protobuf-ts/runtime-rpc";
3
+ import type { MethodInfo, RpcOptions } from "@protobuf-ts/runtime-rpc";
4
+ import {
5
+ RequestHeader,
6
+ ResponseHeader,
7
+ } from "../generated/sid/v1/common/transport";
8
+ import {
9
+ WebTransportConnection,
10
+ WebTransportRpcTransport,
11
+ } from "./webtransport";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Mock WebTransport browser API
15
+ // ---------------------------------------------------------------------------
16
+
17
+ interface MockBidiStream {
18
+ readable: ReadableStream<Uint8Array>;
19
+ writable: WritableStream<Uint8Array>;
20
+ }
21
+
22
+ /** Build a mock bidirectional stream where server sends `responseChunks`. */
23
+ function makeMockBidiStream(responseChunks: Uint8Array[]): {
24
+ stream: MockBidiStream;
25
+ writtenChunks: Uint8Array[];
26
+ } {
27
+ const writtenChunks: Uint8Array[] = [];
28
+
29
+ // Writable side: collects written bytes
30
+ const writable = new WritableStream<Uint8Array>({
31
+ write(chunk) {
32
+ writtenChunks.push(chunk);
33
+ },
34
+ });
35
+
36
+ // Readable side: yields responseChunks one by one
37
+ let i = 0;
38
+ const readable = new ReadableStream<Uint8Array>({
39
+ pull(controller) {
40
+ if (i < responseChunks.length) {
41
+ controller.enqueue(responseChunks[i++]);
42
+ } else {
43
+ controller.close();
44
+ }
45
+ },
46
+ });
47
+
48
+ return { stream: { readable, writable }, writtenChunks };
49
+ }
50
+
51
+ /** Build a ResponseHeader frame (length-prefix + proto bytes). */
52
+ function buildResponseFrame(header: ResponseHeader): Uint8Array {
53
+ const payload = ResponseHeader.toBinary(header);
54
+ const lenBuf = new Uint8Array(4);
55
+ new DataView(lenBuf.buffer).setUint32(0, payload.length, false);
56
+ const frame = new Uint8Array(4 + payload.length);
57
+ frame.set(lenBuf);
58
+ frame.set(payload, 4);
59
+ return frame;
60
+ }
61
+
62
+ /** Build a body frame (length-prefix + raw bytes). */
63
+ function buildBodyFrame(body: Uint8Array): Uint8Array {
64
+ const lenBuf = new Uint8Array(4);
65
+ new DataView(lenBuf.buffer).setUint32(0, body.length, false);
66
+ const frame = new Uint8Array(4 + body.length);
67
+ frame.set(lenBuf);
68
+ frame.set(body, 4);
69
+ return frame;
70
+ }
71
+
72
+ /** Parse a length-prefix frame from a flat byte array (for assertion helpers). */
73
+ function parseFrame(
74
+ bytes: Uint8Array,
75
+ offset: number,
76
+ ): { payload: Uint8Array; next: number } {
77
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
78
+ const len = view.getUint32(offset, false);
79
+ const payload = bytes.slice(offset + 4, offset + 4 + len);
80
+ return { payload, next: offset + 4 + len };
81
+ }
82
+
83
+ /** Concatenate all chunks into a single Uint8Array. */
84
+ function concat(chunks: Uint8Array[]): Uint8Array {
85
+ const total = chunks.reduce((n, c) => n + c.length, 0);
86
+ const out = new Uint8Array(total);
87
+ let offset = 0;
88
+ for (const c of chunks) {
89
+ out.set(c, offset);
90
+ offset += c.length;
91
+ }
92
+ return out;
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Test fixture: minimal MethodInfo
97
+ // ---------------------------------------------------------------------------
98
+
99
+ interface Msg {
100
+ value: string;
101
+ }
102
+
103
+ function makeMethod(): MethodInfo<Msg, Msg> {
104
+ return {
105
+ service: { typeName: "sid.v1.TestService", methods: [], options: {} },
106
+ name: "TestRpc",
107
+ localName: "testRpc",
108
+ idempotency: undefined,
109
+ serverStreaming: false,
110
+ clientStreaming: false,
111
+ I: {
112
+ typeName: "TestInput",
113
+ toBinary: (msg: Msg) => new TextEncoder().encode(JSON.stringify(msg)),
114
+ fromBinary: (bytes: Uint8Array) =>
115
+ JSON.parse(new TextDecoder().decode(bytes)) as Msg,
116
+ },
117
+ O: {
118
+ typeName: "TestOutput",
119
+ toBinary: (msg: Msg) => new TextEncoder().encode(JSON.stringify(msg)),
120
+ fromBinary: (bytes: Uint8Array) =>
121
+ JSON.parse(new TextDecoder().decode(bytes)) as Msg,
122
+ },
123
+ options: {},
124
+ } as unknown as MethodInfo<Msg, Msg>;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // WebTransportConnection tests
129
+ // ---------------------------------------------------------------------------
130
+
131
+ describe("WebTransportConnection", () => {
132
+ let mockWt: {
133
+ ready: Promise<void>;
134
+ closed: Promise<void>;
135
+ close: ReturnType<typeof vi.fn>;
136
+ createBidirectionalStream: ReturnType<typeof vi.fn>;
137
+ };
138
+
139
+ beforeEach(() => {
140
+ mockWt = {
141
+ ready: Promise.resolve(),
142
+ closed: new Promise(() => {}), // never resolves during test
143
+ close: vi.fn(),
144
+ createBidirectionalStream: vi.fn(),
145
+ };
146
+ (globalThis as Record<string, unknown>).WebTransport = vi
147
+ .fn()
148
+ .mockImplementation(() => mockWt);
149
+ });
150
+
151
+ afterEach(() => {
152
+ delete (globalThis as Record<string, unknown>).WebTransport;
153
+ });
154
+
155
+ it("connects successfully and marks connected", async () => {
156
+ const conn = new WebTransportConnection({
157
+ url: "https://wt.sid.example.com",
158
+ });
159
+ expect(conn.connected).toBe(false);
160
+
161
+ await conn.connect();
162
+ expect(conn.connected).toBe(true);
163
+ });
164
+
165
+ it("connect() is idempotent — does not open second connection", async () => {
166
+ const conn = new WebTransportConnection({
167
+ url: "https://wt.sid.example.com",
168
+ });
169
+ await conn.connect();
170
+ await conn.connect();
171
+ await conn.connect();
172
+
173
+ expect(
174
+ (globalThis as Record<string, unknown>).WebTransport,
175
+ ).toHaveBeenCalledTimes(1);
176
+ });
177
+
178
+ it("close() disconnects and allows reconnect", async () => {
179
+ const conn = new WebTransportConnection({
180
+ url: "https://wt.sid.example.com",
181
+ });
182
+ await conn.connect();
183
+
184
+ conn.close();
185
+ expect(conn.connected).toBe(false);
186
+ expect(mockWt.close).toHaveBeenCalled();
187
+
188
+ // Can reconnect after close
189
+ await conn.connect();
190
+ expect(conn.connected).toBe(true);
191
+ expect(
192
+ (globalThis as Record<string, unknown>).WebTransport,
193
+ ).toHaveBeenCalledTimes(2);
194
+ });
195
+
196
+ it("detects remote disconnection via wt.closed", async () => {
197
+ let resolveClosedPromise!: () => void;
198
+ mockWt.closed = new Promise<void>((resolve) => {
199
+ resolveClosedPromise = resolve;
200
+ });
201
+
202
+ const conn = new WebTransportConnection({
203
+ url: "https://wt.sid.example.com",
204
+ });
205
+ await conn.connect();
206
+ expect(conn.connected).toBe(true);
207
+
208
+ // Simulate server closing the connection
209
+ resolveClosedPromise();
210
+ await Promise.resolve(); // let microtasks run
211
+
212
+ expect(conn.connected).toBe(false);
213
+ });
214
+
215
+ it("passes cert hash as serverCertificateHashes when provided", async () => {
216
+ const certHash = "deadbeef1234567890abcdef";
217
+ const conn = new WebTransportConnection({
218
+ url: "https://wt.sid.example.com",
219
+ certHash,
220
+ });
221
+ await conn.connect();
222
+
223
+ const WebTransportCtor = (globalThis as Record<string, unknown>)
224
+ .WebTransport as ReturnType<typeof vi.fn>;
225
+ const initOpts = WebTransportCtor.mock.calls[0][1] as {
226
+ serverCertificateHashes?: unknown[];
227
+ };
228
+ expect(initOpts.serverCertificateHashes).toBeDefined();
229
+ expect(
230
+ (initOpts.serverCertificateHashes as Array<{ algorithm: string }>)[0]
231
+ .algorithm,
232
+ ).toBe("sha-256");
233
+ });
234
+
235
+ it("does not pass serverCertificateHashes when no cert hash", async () => {
236
+ const conn = new WebTransportConnection({
237
+ url: "https://wt.sid.example.com",
238
+ });
239
+ await conn.connect();
240
+
241
+ const WebTransportCtor = (globalThis as Record<string, unknown>)
242
+ .WebTransport as ReturnType<typeof vi.fn>;
243
+ const initOpts = WebTransportCtor.mock.calls[0][1] as Record<
244
+ string,
245
+ unknown
246
+ >;
247
+ expect(initOpts.serverCertificateHashes).toBeUndefined();
248
+ });
249
+
250
+ it("connect() throws when wt.ready rejects", async () => {
251
+ mockWt.ready = Promise.reject(new Error("TLS handshake failed"));
252
+
253
+ const conn = new WebTransportConnection({
254
+ url: "https://wt.sid.example.com",
255
+ });
256
+ await expect(conn.connect()).rejects.toThrow("TLS handshake failed");
257
+ expect(conn.connected).toBe(false);
258
+ });
259
+
260
+ it("concurrent connect() calls share the same promise (no duplicate connections)", async () => {
261
+ // Delay wt.ready so the connection is in-progress when second connect() is called
262
+ let resolveReady!: () => void;
263
+ mockWt.ready = new Promise<void>((resolve) => {
264
+ resolveReady = resolve;
265
+ });
266
+
267
+ const conn = new WebTransportConnection({
268
+ url: "https://wt.sid.example.com",
269
+ });
270
+
271
+ // Start two connects concurrently — neither has resolved yet
272
+ const p1 = conn.connect();
273
+ const p2 = conn.connect();
274
+
275
+ // Both should be the same promise (or at least both resolve to same transport)
276
+ resolveReady();
277
+ const [wt1, wt2] = await Promise.all([p1, p2]);
278
+
279
+ expect(wt1).toBe(wt2);
280
+ expect(
281
+ (globalThis as Record<string, unknown>).WebTransport,
282
+ ).toHaveBeenCalledTimes(1);
283
+ });
284
+
285
+ it("close() is safe when not connected (no-op)", () => {
286
+ const conn = new WebTransportConnection({
287
+ url: "https://wt.sid.example.com",
288
+ });
289
+ // Never connected — close() should not throw
290
+ expect(() => conn.close()).not.toThrow();
291
+ expect(conn.connected).toBe(false);
292
+ });
293
+
294
+ it("wt.closed after close() does not set connected=false again (already null)", async () => {
295
+ let resolveClosedPromise!: () => void;
296
+ mockWt.closed = new Promise<void>((resolve) => {
297
+ resolveClosedPromise = resolve;
298
+ });
299
+
300
+ const conn = new WebTransportConnection({
301
+ url: "https://wt.sid.example.com",
302
+ });
303
+ await conn.connect();
304
+
305
+ // Manually close before server closes the connection
306
+ conn.close();
307
+ expect(conn.connected).toBe(false);
308
+
309
+ // Now the server closes — wt.closed resolves, but this.transport !== wt (it's null)
310
+ // The handler should be a no-op (not throw, not change state)
311
+ resolveClosedPromise();
312
+ await Promise.resolve();
313
+
314
+ // Still disconnected, no error
315
+ expect(conn.connected).toBe(false);
316
+ });
317
+
318
+ it("connect() can be retried after failure (connecting state is reset)", async () => {
319
+ // First attempt fails
320
+ mockWt.ready = Promise.reject(new Error("connect failed"));
321
+ const conn = new WebTransportConnection({
322
+ url: "https://wt.sid.example.com",
323
+ });
324
+ await expect(conn.connect()).rejects.toThrow("connect failed");
325
+
326
+ // Second attempt succeeds (new mockWt with resolved ready)
327
+ const mockWt2 = {
328
+ ready: Promise.resolve(),
329
+ closed: new Promise<void>(() => {}),
330
+ close: vi.fn(),
331
+ createBidirectionalStream: vi.fn(),
332
+ };
333
+ (globalThis as Record<string, unknown>).WebTransport = vi
334
+ .fn()
335
+ .mockImplementation(() => mockWt2);
336
+
337
+ await conn.connect();
338
+ expect(conn.connected).toBe(true);
339
+ });
340
+ });
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // WebTransportRpcTransport tests
344
+ // ---------------------------------------------------------------------------
345
+
346
+ describe("WebTransportRpcTransport", () => {
347
+ const method = makeMethod();
348
+ const input: Msg = { value: "hello" };
349
+ const options: RpcOptions = { meta: {} };
350
+
351
+ let mockWt: {
352
+ ready: Promise<void>;
353
+ closed: Promise<void>;
354
+ close: ReturnType<typeof vi.fn>;
355
+ createBidirectionalStream: ReturnType<typeof vi.fn>;
356
+ };
357
+ let conn: WebTransportConnection;
358
+
359
+ beforeEach(() => {
360
+ mockWt = {
361
+ ready: Promise.resolve(),
362
+ closed: new Promise(() => {}),
363
+ close: vi.fn(),
364
+ createBidirectionalStream: vi.fn(),
365
+ };
366
+ (globalThis as Record<string, unknown>).WebTransport = vi
367
+ .fn()
368
+ .mockImplementation(() => mockWt);
369
+ conn = new WebTransportConnection({ url: "https://wt.sid.example.com" });
370
+ });
371
+
372
+ afterEach(() => {
373
+ conn.close();
374
+ delete (globalThis as Record<string, unknown>).WebTransport;
375
+ });
376
+
377
+ it("sends RequestHeader with correct method path and gets response", async () => {
378
+ await conn.connect();
379
+
380
+ const responseBody = new TextEncoder().encode(
381
+ JSON.stringify({ value: "world" }),
382
+ );
383
+
384
+ const respHeader: ResponseHeader = {
385
+ statusCode: 0,
386
+ statusMessage: "",
387
+ metadata: {},
388
+ };
389
+ const { stream, writtenChunks } = makeMockBidiStream([
390
+ buildResponseFrame(respHeader),
391
+ buildBodyFrame(responseBody),
392
+ ]);
393
+ mockWt.createBidirectionalStream.mockResolvedValue(stream);
394
+
395
+ const transport = new WebTransportRpcTransport(conn);
396
+ const call = transport.unary(method, input, options);
397
+ const response = await call.response;
398
+
399
+ expect(response).toEqual({ value: "world" });
400
+
401
+ // Verify RequestHeader was sent correctly
402
+ const sent = concat(writtenChunks);
403
+ const { payload: reqHeaderBytes } = parseFrame(sent, 0);
404
+ const sentHeader = RequestHeader.fromBinary(reqHeaderBytes);
405
+ expect(sentHeader.method).toBe("sid.v1.TestService/TestRpc");
406
+ });
407
+
408
+ it("sends metadata from RpcOptions.meta in RequestHeader", async () => {
409
+ await conn.connect();
410
+
411
+ const responseBody = new TextEncoder().encode(
412
+ JSON.stringify({ value: "ok" }),
413
+ );
414
+ const respHeader: ResponseHeader = {
415
+ statusCode: 0,
416
+ statusMessage: "",
417
+ metadata: {},
418
+ };
419
+ const { stream, writtenChunks } = makeMockBidiStream([
420
+ buildResponseFrame(respHeader),
421
+ buildBodyFrame(responseBody),
422
+ ]);
423
+ mockWt.createBidirectionalStream.mockResolvedValue(stream);
424
+
425
+ const transport = new WebTransportRpcTransport(conn);
426
+ const callWithMeta = transport.unary(method, input, {
427
+ meta: { authorization: "Bearer token123", "x-trace-id": "abc" },
428
+ });
429
+ await callWithMeta.response;
430
+
431
+ const sent = concat(writtenChunks);
432
+ const { payload: reqHeaderBytes } = parseFrame(sent, 0);
433
+ const sentHeader = RequestHeader.fromBinary(reqHeaderBytes);
434
+ expect(sentHeader.metadata["authorization"]).toBe("Bearer token123");
435
+ expect(sentHeader.metadata["x-trace-id"]).toBe("abc");
436
+ });
437
+
438
+ it("resolves headers from ResponseHeader.metadata", async () => {
439
+ await conn.connect();
440
+
441
+ const responseBody = new TextEncoder().encode(
442
+ JSON.stringify({ value: "ok" }),
443
+ );
444
+ const respHeader: ResponseHeader = {
445
+ statusCode: 0,
446
+ statusMessage: "",
447
+ metadata: { "x-request-id": "req-456" },
448
+ };
449
+ const { stream } = makeMockBidiStream([
450
+ buildResponseFrame(respHeader),
451
+ buildBodyFrame(responseBody),
452
+ ]);
453
+ mockWt.createBidirectionalStream.mockResolvedValue(stream);
454
+
455
+ const transport = new WebTransportRpcTransport(conn);
456
+ const call = transport.unary(method, input, options);
457
+ const headers = await call.headers;
458
+
459
+ expect(headers["x-request-id"]).toBe("req-456");
460
+ });
461
+
462
+ it("joins array metadata values with comma in RequestHeader", async () => {
463
+ await conn.connect();
464
+
465
+ const responseBody = new TextEncoder().encode(
466
+ JSON.stringify({ value: "ok" }),
467
+ );
468
+ const respHeader: ResponseHeader = {
469
+ statusCode: 0,
470
+ statusMessage: "",
471
+ metadata: {},
472
+ };
473
+ const { stream, writtenChunks } = makeMockBidiStream([
474
+ buildResponseFrame(respHeader),
475
+ buildBodyFrame(responseBody),
476
+ ]);
477
+ mockWt.createBidirectionalStream.mockResolvedValue(stream);
478
+
479
+ const transport = new WebTransportRpcTransport(conn);
480
+ const call = transport.unary(method, input, {
481
+ meta: { accept: ["application/json", "text/plain"] },
482
+ });
483
+ await call.response;
484
+
485
+ const sent = concat(writtenChunks);
486
+ const { payload: reqHeaderBytes } = parseFrame(sent, 0);
487
+ const sentHeader = RequestHeader.fromBinary(reqHeaderBytes);
488
+ expect(sentHeader.metadata["accept"]).toBe("application/json,text/plain");
489
+ });
490
+
491
+ it("includes Date timeout in RequestHeader.timeoutMs (future date)", async () => {
492
+ await conn.connect();
493
+
494
+ const responseBody = new TextEncoder().encode(
495
+ JSON.stringify({ value: "ok" }),
496
+ );
497
+ const respHeader: ResponseHeader = {
498
+ statusCode: 0,
499
+ statusMessage: "",
500
+ metadata: {},
501
+ };
502
+ const { stream, writtenChunks } = makeMockBidiStream([
503
+ buildResponseFrame(respHeader),
504
+ buildBodyFrame(responseBody),
505
+ ]);
506
+ mockWt.createBidirectionalStream.mockResolvedValue(stream);
507
+
508
+ const transport = new WebTransportRpcTransport(conn);
509
+ const deadlineMs = 5000;
510
+ const deadline = new Date(Date.now() + deadlineMs);
511
+ const call = transport.unary(method, input, {
512
+ meta: {},
513
+ timeout: deadline,
514
+ });
515
+ await call.response;
516
+
517
+ const sent = concat(writtenChunks);
518
+ const { payload: reqHeaderBytes } = parseFrame(sent, 0);
519
+ const sentHeader = RequestHeader.fromBinary(reqHeaderBytes);
520
+ // timeoutMs should be roughly 5000 (some drift allowed)
521
+ expect(sentHeader.timeoutMs).toBeGreaterThan(0);
522
+ expect(sentHeader.timeoutMs).toBeLessThanOrEqual(deadlineMs);
523
+ });
524
+
525
+ it("clamps past Date timeout to 0 in RequestHeader.timeoutMs", async () => {
526
+ await conn.connect();
527
+
528
+ const responseBody = new TextEncoder().encode(
529
+ JSON.stringify({ value: "ok" }),
530
+ );
531
+ const respHeader: ResponseHeader = {
532
+ statusCode: 0,
533
+ statusMessage: "",
534
+ metadata: {},
535
+ };
536
+ const { stream, writtenChunks } = makeMockBidiStream([
537
+ buildResponseFrame(respHeader),
538
+ buildBodyFrame(responseBody),
539
+ ]);
540
+ mockWt.createBidirectionalStream.mockResolvedValue(stream);
541
+
542
+ const transport = new WebTransportRpcTransport(conn);
543
+ const pastDate = new Date(Date.now() - 10000); // 10 seconds ago
544
+ const call = transport.unary(method, input, {
545
+ meta: {},
546
+ timeout: pastDate,
547
+ });
548
+ await call.response;
549
+
550
+ const sent = concat(writtenChunks);
551
+ const { payload: reqHeaderBytes } = parseFrame(sent, 0);
552
+ const sentHeader = RequestHeader.fromBinary(reqHeaderBytes);
553
+ expect(sentHeader.timeoutMs).toBe(0);
554
+ });
555
+
556
+ it("includes numeric timeout in RequestHeader.timeoutMs", async () => {
557
+ await conn.connect();
558
+
559
+ const responseBody = new TextEncoder().encode(
560
+ JSON.stringify({ value: "ok" }),
561
+ );
562
+ const respHeader: ResponseHeader = {
563
+ statusCode: 0,
564
+ statusMessage: "",
565
+ metadata: {},
566
+ };
567
+ const { stream, writtenChunks } = makeMockBidiStream([
568
+ buildResponseFrame(respHeader),
569
+ buildBodyFrame(responseBody),
570
+ ]);
571
+ mockWt.createBidirectionalStream.mockResolvedValue(stream);
572
+
573
+ const transport = new WebTransportRpcTransport(conn);
574
+ const call = transport.unary(method, input, { meta: {}, timeout: 3000 });
575
+ await call.response;
576
+
577
+ const sent = concat(writtenChunks);
578
+ const { payload: reqHeaderBytes } = parseFrame(sent, 0);
579
+ const sentHeader = RequestHeader.fromBinary(reqHeaderBytes);
580
+ expect(sentHeader.timeoutMs).toBe(3000);
581
+ });
582
+
583
+ it("uses 'RPC error' as message when statusMessage is empty", async () => {
584
+ await conn.connect();
585
+
586
+ const respHeader: ResponseHeader = {
587
+ statusCode: 5, // NOT_FOUND
588
+ statusMessage: "", // empty — code should fall back to "RPC error"
589
+ metadata: {},
590
+ };
591
+ const { stream } = makeMockBidiStream([buildResponseFrame(respHeader)]);
592
+ mockWt.createBidirectionalStream.mockResolvedValue(stream);
593
+
594
+ const transport = new WebTransportRpcTransport(conn);
595
+ const call = transport.unary(method, input, options);
596
+
597
+ const err = await call.response.catch((e: unknown) => e);
598
+ expect(err).toBeInstanceOf(RpcError);
599
+ expect((err as RpcError).code).toBe("NOT_FOUND");
600
+ expect((err as RpcError).message).toBe("RPC error");
601
+ });
602
+
603
+ it("throws RpcError on non-zero gRPC status code", async () => {
604
+ await conn.connect();
605
+
606
+ const respHeader: ResponseHeader = {
607
+ statusCode: 5, // NOT_FOUND
608
+ statusMessage: "record not found",
609
+ metadata: {},
610
+ };
611
+ const { stream } = makeMockBidiStream([buildResponseFrame(respHeader)]);
612
+ mockWt.createBidirectionalStream.mockResolvedValue(stream);
613
+
614
+ const transport = new WebTransportRpcTransport(conn);
615
+ const call = transport.unary(method, input, options);
616
+
617
+ const err = await call.response.catch((e: unknown) => e);
618
+ expect(err).toBeInstanceOf(RpcError);
619
+ expect((err as RpcError).code).toBe("NOT_FOUND");
620
+ expect((err as RpcError).message).toBe("record not found");
621
+ });
622
+
623
+ it("maps all gRPC status codes correctly", async () => {
624
+ const cases: Array<{ code: number; name: string }> = [
625
+ { code: 1, name: "CANCELLED" },
626
+ { code: 2, name: "UNKNOWN" },
627
+ { code: 3, name: "INVALID_ARGUMENT" },
628
+ { code: 4, name: "DEADLINE_EXCEEDED" },
629
+ { code: 7, name: "PERMISSION_DENIED" },
630
+ { code: 14, name: "UNAVAILABLE" },
631
+ { code: 16, name: "UNAUTHENTICATED" },
632
+ ];
633
+
634
+ for (const { code, name } of cases) {
635
+ await conn.connect();
636
+ const respHeader: ResponseHeader = {
637
+ statusCode: code,
638
+ statusMessage: name,
639
+ metadata: {},
640
+ };
641
+ const { stream } = makeMockBidiStream([buildResponseFrame(respHeader)]);
642
+ mockWt.createBidirectionalStream.mockResolvedValue(stream);
643
+
644
+ const transport = new WebTransportRpcTransport(conn);
645
+ const call = transport.unary(method, input, options);
646
+ const err = await call.response.catch((e: unknown) => e);
647
+
648
+ expect((err as RpcError).code).toBe(name);
649
+ conn.close();
650
+ conn = new WebTransportConnection({ url: "https://wt.sid.example.com" });
651
+ }
652
+ });
653
+
654
+ it("falls back to UNKNOWN for unrecognized gRPC status code", async () => {
655
+ await conn.connect();
656
+
657
+ const respHeader: ResponseHeader = {
658
+ statusCode: 999, // not in the grpcCodeName table
659
+ statusMessage: "mystery error",
660
+ metadata: {},
661
+ };
662
+ const { stream } = makeMockBidiStream([buildResponseFrame(respHeader)]);
663
+ mockWt.createBidirectionalStream.mockResolvedValue(stream);
664
+
665
+ const transport = new WebTransportRpcTransport(conn);
666
+ const call = transport.unary(method, input, options);
667
+
668
+ const err = await call.response.catch((e: unknown) => e);
669
+ expect(err).toBeInstanceOf(RpcError);
670
+ expect((err as RpcError).code).toBe("UNKNOWN");
671
+ });
672
+
673
+ it("throws UNAVAILABLE if connection not yet established", async () => {
674
+ // conn not connected
675
+ const transport = new WebTransportRpcTransport(conn);
676
+ const call = transport.unary(method, input, options);
677
+
678
+ const err = await call.response.catch((e: unknown) => e);
679
+ expect(err).toBeInstanceOf(RpcError);
680
+ expect((err as RpcError).code).toBe("UNAVAILABLE");
681
+ });
682
+
683
+ it("throws INTERNAL on stream error (non-RpcError)", async () => {
684
+ await conn.connect();
685
+ mockWt.createBidirectionalStream.mockRejectedValue(
686
+ new Error("stream creation failed"),
687
+ );
688
+
689
+ const transport = new WebTransportRpcTransport(conn);
690
+ const call = transport.unary(method, input, options);
691
+
692
+ const err = await call.response.catch((e: unknown) => e);
693
+ expect(err).toBeInstanceOf(RpcError);
694
+ expect((err as RpcError).code).toBe("INTERNAL");
695
+ expect((err as RpcError).message).toBe("stream creation failed");
696
+ });
697
+
698
+ it("uses String(err) as message when thrown value is not an Error", async () => {
699
+ await conn.connect();
700
+ // Simulate a non-Error non-RpcError thrown value (rare but possible)
701
+ mockWt.createBidirectionalStream.mockRejectedValue("stream aborted");
702
+
703
+ const transport = new WebTransportRpcTransport(conn);
704
+ const call = transport.unary(method, input, options);
705
+
706
+ const err = await call.response.catch((e: unknown) => e);
707
+ expect(err).toBeInstanceOf(RpcError);
708
+ expect((err as RpcError).code).toBe("INTERNAL");
709
+ expect((err as RpcError).message).toBe("stream aborted");
710
+ });
711
+
712
+ it("serverStreaming throws RpcError", () => {
713
+ const transport = new WebTransportRpcTransport(conn);
714
+ expect(() => transport.serverStreaming(method, input, options)).toThrow(
715
+ RpcError,
716
+ );
717
+ });
718
+
719
+ it("clientStreaming throws RpcError", () => {
720
+ const transport = new WebTransportRpcTransport(conn);
721
+ expect(() => transport.clientStreaming(method, options)).toThrow(RpcError);
722
+ });
723
+
724
+ it("duplex throws RpcError", () => {
725
+ const transport = new WebTransportRpcTransport(conn);
726
+ expect(() => transport.duplex(method, options)).toThrow(RpcError);
727
+ });
728
+ });