@structured-id/ui-core 4.9.0 → 4.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@structured-id/ui-core",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -42,13 +42,14 @@
|
|
|
42
42
|
"@protobuf-ts/grpcweb-transport": "^2.11.1",
|
|
43
43
|
"@protobuf-ts/runtime": "^2.11.1",
|
|
44
44
|
"@protobuf-ts/runtime-rpc": "^2.11.1",
|
|
45
|
-
"@structured-id/proto": "^0.
|
|
45
|
+
"@structured-id/proto": "^0.9.0",
|
|
46
46
|
"@structured-world/structured-email-address-ts": "^0.0.22",
|
|
47
47
|
"@structured-world/structured-public-domains": "0.0.x",
|
|
48
48
|
"libphonenumber-js": "^1.13.15"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@types/node": "^26",
|
|
52
|
+
"@typescript/native": "npm:typescript@^7.0.2",
|
|
52
53
|
"@vitejs/plugin-vue": "^6.0.9",
|
|
53
54
|
"@vue/compiler-dom": "^3.5.43",
|
|
54
55
|
"@vue/devtools-api": "^8.2.1",
|
|
@@ -57,7 +58,7 @@
|
|
|
57
58
|
"jsdom": "^30.1.2",
|
|
58
59
|
"pinia": "^4.0.3",
|
|
59
60
|
"prettier": "^3.9.9",
|
|
60
|
-
"typescript": "
|
|
61
|
+
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
61
62
|
"typescript-eslint": "^8.71.1",
|
|
62
63
|
"vite": "^8.3.4",
|
|
63
64
|
"vitest": "^5.0.3",
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import type { MethodInfo, RpcOptions } from "@protobuf-ts/runtime-rpc";
|
|
3
|
+
import { OperationService } from "@structured-id/proto/sid/v1/common/operation";
|
|
4
|
+
import {
|
|
5
|
+
OPERATION_KEY,
|
|
6
|
+
OPERATION_PERIOD,
|
|
7
|
+
encodeOperationKey,
|
|
8
|
+
isKeyed,
|
|
9
|
+
operationContext,
|
|
10
|
+
} from "./operationContext";
|
|
11
|
+
|
|
12
|
+
const KEYED = {
|
|
13
|
+
name: "CreateProject",
|
|
14
|
+
options: { "sid.v1.common.retry": { keyed: true } },
|
|
15
|
+
} as unknown as MethodInfo;
|
|
16
|
+
const CEREMONY = {
|
|
17
|
+
name: "RegisterClient",
|
|
18
|
+
options: { "sid.v1.common.retry": { ceremony: "RFC 7591 §3" } },
|
|
19
|
+
} as unknown as MethodInfo;
|
|
20
|
+
const READ = { name: "GetProject", options: {} } as unknown as MethodInfo;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A chain answering the period read with `period` until `untilMs`; with
|
|
24
|
+
* `refused`, a command's response is refused while its headers, status and
|
|
25
|
+
* trailers arrive, as a non-OK gRPC answer does.
|
|
26
|
+
*/
|
|
27
|
+
function chain(
|
|
28
|
+
period: bigint,
|
|
29
|
+
untilMs = Date.now() + 3_600_000,
|
|
30
|
+
refused = false,
|
|
31
|
+
) {
|
|
32
|
+
const sent: Array<{ method: MethodInfo; options: RpcOptions }> = [];
|
|
33
|
+
const next = vi.fn(
|
|
34
|
+
(method: MethodInfo, _input: object, options: RpcOptions) => {
|
|
35
|
+
sent.push({ method, options });
|
|
36
|
+
const read = method.name === OperationService.methods[0].name;
|
|
37
|
+
const response = read
|
|
38
|
+
? Promise.resolve({
|
|
39
|
+
period,
|
|
40
|
+
acceptedUntil: {
|
|
41
|
+
seconds: BigInt(Math.floor(untilMs / 1000)),
|
|
42
|
+
nanos: 0,
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
: refused
|
|
46
|
+
? Promise.reject(new Error("refused"))
|
|
47
|
+
: Promise.resolve({});
|
|
48
|
+
response.catch(() => {});
|
|
49
|
+
const status = Promise.resolve({
|
|
50
|
+
code: refused && !read ? "FAILED_PRECONDITION" : "OK",
|
|
51
|
+
detail: "",
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
headers: Promise.resolve({ answered: "yes" }),
|
|
55
|
+
response,
|
|
56
|
+
status,
|
|
57
|
+
trailers: Promise.resolve({}),
|
|
58
|
+
then: (f: (v: unknown) => unknown, r?: (e: unknown) => unknown) =>
|
|
59
|
+
response.then(f, r),
|
|
60
|
+
} as never;
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
return { next, sent };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The options the keyed (or other) call reached the transport with. */
|
|
67
|
+
async function call(
|
|
68
|
+
interceptor = operationContext(),
|
|
69
|
+
c = chain(7n),
|
|
70
|
+
method = KEYED,
|
|
71
|
+
options: RpcOptions = {},
|
|
72
|
+
) {
|
|
73
|
+
const answered = interceptor.interceptUnary!(
|
|
74
|
+
c.next as never,
|
|
75
|
+
method,
|
|
76
|
+
{},
|
|
77
|
+
options,
|
|
78
|
+
);
|
|
79
|
+
await answered.response;
|
|
80
|
+
return c.sent.filter((s) => s.method.name === method.name).at(-1)!.options;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe("operation context", () => {
|
|
84
|
+
// The retry rule declared on the method decides, not its idempotency
|
|
85
|
+
// level: a ceremony or a read carries no context.
|
|
86
|
+
it("names only keyed methods", () => {
|
|
87
|
+
expect(isKeyed(KEYED)).toBe(true);
|
|
88
|
+
expect(isKeyed(CEREMONY)).toBe(false);
|
|
89
|
+
expect(isKeyed(READ)).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// A new keyed command goes out with a fresh Structured Field String key
|
|
93
|
+
// and the period the server issued.
|
|
94
|
+
it("names a new command by a key and the server's period", async () => {
|
|
95
|
+
const meta = (await call()).meta!;
|
|
96
|
+
expect(meta[OPERATION_KEY]).toMatch(/^"[0-9a-f-]{36}"$/);
|
|
97
|
+
expect(meta[OPERATION_PERIOD]).toBe("7");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("adds nothing to a call that is not keyed", async () => {
|
|
101
|
+
const c = chain(7n);
|
|
102
|
+
const options = await call(operationContext(), c, CEREMONY, {
|
|
103
|
+
meta: { other: "x" },
|
|
104
|
+
});
|
|
105
|
+
expect(options.meta).toEqual({ other: "x" });
|
|
106
|
+
expect(c.sent).toHaveLength(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// A retry of one command repeats the context it was first sent with: the
|
|
110
|
+
// server finds the recorded result only under the same key and period.
|
|
111
|
+
it("keeps the context a retrying caller passes", async () => {
|
|
112
|
+
const c = chain(9n);
|
|
113
|
+
const given = { [OPERATION_KEY]: '"mine"', [OPERATION_PERIOD]: "3" };
|
|
114
|
+
const options = await call(operationContext(), c, KEYED, { meta: given });
|
|
115
|
+
expect(options.meta).toEqual(given);
|
|
116
|
+
expect(c.sent).toHaveLength(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// One period serves many commands: it is read once, not per call, and
|
|
120
|
+
// concurrent first calls share that read.
|
|
121
|
+
it("reads the period once for many commands", async () => {
|
|
122
|
+
const interceptor = operationContext();
|
|
123
|
+
const c = chain(7n);
|
|
124
|
+
await Promise.all([call(interceptor, c), call(interceptor, c)]);
|
|
125
|
+
await call(interceptor, c);
|
|
126
|
+
const reads = c.sent.filter(
|
|
127
|
+
(s) => s.method.name === OperationService.methods[0].name,
|
|
128
|
+
);
|
|
129
|
+
expect(reads).toHaveLength(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// A period about to stop being admitted is replaced before a new command
|
|
133
|
+
// is named with it.
|
|
134
|
+
it("reads a new period when the current one is about to expire", async () => {
|
|
135
|
+
const interceptor = operationContext();
|
|
136
|
+
const c = chain(7n, Date.now() + 1_000);
|
|
137
|
+
await call(interceptor, c);
|
|
138
|
+
await call(interceptor, c);
|
|
139
|
+
const reads = c.sent.filter(
|
|
140
|
+
(s) => s.method.name === OperationService.methods[0].name,
|
|
141
|
+
);
|
|
142
|
+
expect(reads).toHaveLength(2);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// The period read is not itself a keyed command.
|
|
146
|
+
it("sends the period read without the call's key", async () => {
|
|
147
|
+
const c = chain(7n);
|
|
148
|
+
await call(operationContext(), c, KEYED, {
|
|
149
|
+
meta: { [OPERATION_KEY]: '"mine"', other: "x" },
|
|
150
|
+
});
|
|
151
|
+
const read = c.sent.find(
|
|
152
|
+
(s) => s.method.name === OperationService.methods[0].name,
|
|
153
|
+
)!;
|
|
154
|
+
expect(read.options.meta).toEqual({ other: "x" });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("gives every command its own key", async () => {
|
|
158
|
+
const interceptor = operationContext();
|
|
159
|
+
const c = chain(7n);
|
|
160
|
+
const a = (await call(interceptor, c)).meta![OPERATION_KEY];
|
|
161
|
+
const b = (await call(interceptor, c)).meta![OPERATION_KEY];
|
|
162
|
+
expect(a).not.toBe(b);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// A caller keeping the call's request headers retries the same command:
|
|
166
|
+
// they hold the key and the period the command was actually sent with.
|
|
167
|
+
it("shows the context it sent in the call's request headers", async () => {
|
|
168
|
+
const c = chain(7n);
|
|
169
|
+
const answered = operationContext().interceptUnary!(
|
|
170
|
+
c.next as never,
|
|
171
|
+
KEYED,
|
|
172
|
+
{},
|
|
173
|
+
{},
|
|
174
|
+
);
|
|
175
|
+
await answered.response;
|
|
176
|
+
const sent = c.sent.find((s) => s.method.name === KEYED.name)!.options;
|
|
177
|
+
expect(answered.requestHeaders[OPERATION_KEY]).toBe(
|
|
178
|
+
sent.meta![OPERATION_KEY],
|
|
179
|
+
);
|
|
180
|
+
expect(answered.requestHeaders[OPERATION_PERIOD]).toBe("7");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// A refused command still has its own headers, status and trailers: the
|
|
184
|
+
// wrapper forwards each, rather than failing them all with the response.
|
|
185
|
+
it("keeps a refused command's status and headers", async () => {
|
|
186
|
+
const answered = operationContext().interceptUnary!(
|
|
187
|
+
chain(7n, undefined, true).next as never,
|
|
188
|
+
KEYED,
|
|
189
|
+
{},
|
|
190
|
+
{},
|
|
191
|
+
);
|
|
192
|
+
await expect(answered.response).rejects.toThrow("refused");
|
|
193
|
+
expect((await answered.status).code).toBe("FAILED_PRECONDITION");
|
|
194
|
+
expect(await answered.headers).toEqual({ answered: "yes" });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// The period read is shared by concurrent commands: one caller's
|
|
198
|
+
// cancellation or deadline is not passed to it, so the others go on.
|
|
199
|
+
it("does not give the shared period read one caller's cancellation", async () => {
|
|
200
|
+
const interceptor = operationContext();
|
|
201
|
+
const c = chain(7n);
|
|
202
|
+
const cancelled = new AbortController();
|
|
203
|
+
const first = interceptor.interceptUnary!(
|
|
204
|
+
c.next as never,
|
|
205
|
+
KEYED,
|
|
206
|
+
{},
|
|
207
|
+
{
|
|
208
|
+
abort: cancelled.signal,
|
|
209
|
+
timeout: 5,
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
const second = interceptor.interceptUnary!(c.next as never, KEYED, {}, {});
|
|
213
|
+
cancelled.abort();
|
|
214
|
+
await second.response;
|
|
215
|
+
first.response.catch(() => {});
|
|
216
|
+
const read = c.sent.find(
|
|
217
|
+
(s) => s.method.name === OperationService.methods[0].name,
|
|
218
|
+
)!;
|
|
219
|
+
expect(read.options.abort).toBeUndefined();
|
|
220
|
+
// Its own deadline, not the first caller's.
|
|
221
|
+
expect(read.options.timeout).not.toBe(5);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// A period read that stalls does not hold a caller past its own
|
|
225
|
+
// cancellation or deadline, and the shared read has a deadline of its own.
|
|
226
|
+
it("ends a caller's wait for the period at its own cancellation or deadline", async () => {
|
|
227
|
+
const sent: RpcOptions[] = [];
|
|
228
|
+
const stalled = vi.fn((_m: MethodInfo, _i: object, options: RpcOptions) => {
|
|
229
|
+
sent.push(options);
|
|
230
|
+
const never = new Promise<never>(() => {});
|
|
231
|
+
return {
|
|
232
|
+
headers: never,
|
|
233
|
+
response: never,
|
|
234
|
+
status: never,
|
|
235
|
+
trailers: never,
|
|
236
|
+
} as never;
|
|
237
|
+
});
|
|
238
|
+
const interceptor = operationContext();
|
|
239
|
+
const cancelled = new AbortController();
|
|
240
|
+
const aborted = interceptor.interceptUnary!(
|
|
241
|
+
stalled as never,
|
|
242
|
+
KEYED,
|
|
243
|
+
{},
|
|
244
|
+
{
|
|
245
|
+
abort: cancelled.signal,
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
cancelled.abort();
|
|
249
|
+
await expect(aborted.response).rejects.toMatchObject({ code: "CANCELLED" });
|
|
250
|
+
|
|
251
|
+
const late = interceptor.interceptUnary!(
|
|
252
|
+
stalled as never,
|
|
253
|
+
KEYED,
|
|
254
|
+
{},
|
|
255
|
+
{
|
|
256
|
+
timeout: 5,
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
await expect(late.response).rejects.toMatchObject({
|
|
260
|
+
code: "DEADLINE_EXCEEDED",
|
|
261
|
+
});
|
|
262
|
+
expect(typeof sent[0].timeout).toBe("number");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// RFC 9651 §3.3.3: a String escapes DQUOTE and backslash and holds
|
|
266
|
+
// printable ASCII only.
|
|
267
|
+
it("encodes a key as a Structured Field String", () => {
|
|
268
|
+
expect(encodeOperationKey('a"b\\c')).toBe('"a\\"b\\\\c"');
|
|
269
|
+
expect(() => encodeOperationKey("é")).toThrow(RangeError);
|
|
270
|
+
expect(() => encodeOperationKey("")).toThrow(RangeError);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operation context of keyed calls: SID answers a retried command with
|
|
3
|
+
* its recorded result only when the call names it by a key the caller
|
|
4
|
+
* created before sending it and the server-issued period it was created
|
|
5
|
+
* under. Which methods are keyed is the method's own retry rule.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
RpcError,
|
|
9
|
+
UnaryCall,
|
|
10
|
+
type MethodInfo,
|
|
11
|
+
type RpcInterceptor,
|
|
12
|
+
type RpcMetadata,
|
|
13
|
+
} from "@protobuf-ts/runtime-rpc";
|
|
14
|
+
import {
|
|
15
|
+
GetOperationPeriodRequest,
|
|
16
|
+
OperationService,
|
|
17
|
+
type OperationPeriod,
|
|
18
|
+
} from "@structured-id/proto/sid/v1/common/operation";
|
|
19
|
+
|
|
20
|
+
/** The metadata entry carrying the key (the `Idempotency-Key` header). */
|
|
21
|
+
export const OPERATION_KEY = "idempotency-key";
|
|
22
|
+
|
|
23
|
+
/** The metadata entry carrying the period (the `SID-Operation-Period` header). */
|
|
24
|
+
export const OPERATION_PERIOD = "sid-operation-period";
|
|
25
|
+
|
|
26
|
+
/** The method option holding a method's retry rule. */
|
|
27
|
+
const RETRY_RULE = "sid.v1.common.retry";
|
|
28
|
+
|
|
29
|
+
/** A period is fetched again this long before the server stops admitting it. */
|
|
30
|
+
const PERIOD_MARGIN_MS = 60_000;
|
|
31
|
+
|
|
32
|
+
/** The shared period read's own deadline: a stalled read is not kept. */
|
|
33
|
+
const PERIOD_READ_TIMEOUT_MS = 10_000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `waited`, ended by the caller's own cancellation or deadline as a call
|
|
37
|
+
* would end: CANCELLED or DEADLINE_EXCEEDED.
|
|
38
|
+
*/
|
|
39
|
+
function within<T>(
|
|
40
|
+
waited: Promise<T>,
|
|
41
|
+
abort: AbortSignal | undefined,
|
|
42
|
+
deadline: Date | undefined,
|
|
43
|
+
): Promise<T> {
|
|
44
|
+
return new Promise<T>((resolve, reject) => {
|
|
45
|
+
const cancelled = () =>
|
|
46
|
+
reject(new RpcError("the call was cancelled", "CANCELLED"));
|
|
47
|
+
if (abort?.aborted) {
|
|
48
|
+
cancelled();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
abort?.addEventListener("abort", cancelled, { once: true });
|
|
52
|
+
const timer =
|
|
53
|
+
deadline === undefined
|
|
54
|
+
? undefined
|
|
55
|
+
: setTimeout(
|
|
56
|
+
() =>
|
|
57
|
+
reject(
|
|
58
|
+
new RpcError("the call's deadline passed", "DEADLINE_EXCEEDED"),
|
|
59
|
+
),
|
|
60
|
+
Math.max(0, deadline.getTime() - Date.now()),
|
|
61
|
+
);
|
|
62
|
+
const settle = () => {
|
|
63
|
+
abort?.removeEventListener("abort", cancelled);
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
};
|
|
66
|
+
waited.then(
|
|
67
|
+
(value) => {
|
|
68
|
+
settle();
|
|
69
|
+
resolve(value);
|
|
70
|
+
},
|
|
71
|
+
(error: unknown) => {
|
|
72
|
+
settle();
|
|
73
|
+
reject(error);
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether `method` takes an operation context: its retry rule says keyed. */
|
|
80
|
+
export function isKeyed(method: MethodInfo): boolean {
|
|
81
|
+
const rule = (method.options as Record<string, unknown> | undefined)?.[
|
|
82
|
+
RETRY_RULE
|
|
83
|
+
] as { keyed?: boolean } | undefined;
|
|
84
|
+
return rule?.keyed === true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `key` as the header value: a Structured Field String (RFC 9651 §3.3.3,
|
|
89
|
+
* required by draft-ietf-httpapi-idempotency-key-header-08 §2.1), the same
|
|
90
|
+
* encoded value in gRPC metadata and HTTP.
|
|
91
|
+
*/
|
|
92
|
+
export function encodeOperationKey(key: string): string {
|
|
93
|
+
// RFC 9651 §3.3.3: a String holds printable ASCII only.
|
|
94
|
+
if (!/^[\x20-\x7e]+$/.test(key)) {
|
|
95
|
+
throw new RangeError("an operation key is printable ASCII");
|
|
96
|
+
}
|
|
97
|
+
return `"${key.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* An interceptor naming every new keyed command by a fresh key and the
|
|
102
|
+
* current period; other calls pass untouched. A caller retrying one command
|
|
103
|
+
* passes the key and period it first sent, and they are kept. The period is
|
|
104
|
+
* read from the server the calls go to, once, and again only when it is
|
|
105
|
+
* about to stop being admitted.
|
|
106
|
+
*/
|
|
107
|
+
export function operationContext(): RpcInterceptor {
|
|
108
|
+
let current: { period: string; until: number } | null = null;
|
|
109
|
+
let pending: Promise<string> | null = null;
|
|
110
|
+
|
|
111
|
+
function period(fetch: () => Promise<OperationPeriod>): Promise<string> {
|
|
112
|
+
if (current && Date.now() < current.until) {
|
|
113
|
+
return Promise.resolve(current.period);
|
|
114
|
+
}
|
|
115
|
+
// Concurrent first calls share one read.
|
|
116
|
+
pending ??= fetch()
|
|
117
|
+
.then((answer) => {
|
|
118
|
+
const until = answer.acceptedUntil
|
|
119
|
+
? Number(answer.acceptedUntil.seconds) * 1000 +
|
|
120
|
+
Math.floor(answer.acceptedUntil.nanos / 1e6)
|
|
121
|
+
: 0;
|
|
122
|
+
current = {
|
|
123
|
+
period: answer.period.toString(),
|
|
124
|
+
until: until - PERIOD_MARGIN_MS,
|
|
125
|
+
};
|
|
126
|
+
return current.period;
|
|
127
|
+
})
|
|
128
|
+
.finally(() => {
|
|
129
|
+
pending = null;
|
|
130
|
+
});
|
|
131
|
+
return pending;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
interceptUnary(next, method, input, options) {
|
|
136
|
+
if (!isKeyed(method)) {
|
|
137
|
+
return next(method, input, options);
|
|
138
|
+
}
|
|
139
|
+
const meta = options.meta ?? {};
|
|
140
|
+
// The headers the command is sent with; the call shows them, so a
|
|
141
|
+
// caller keeping them retries the same command.
|
|
142
|
+
const sent: RpcMetadata = {
|
|
143
|
+
...meta,
|
|
144
|
+
[OPERATION_KEY]:
|
|
145
|
+
meta[OPERATION_KEY] ?? encodeOperationKey(crypto.randomUUID()),
|
|
146
|
+
};
|
|
147
|
+
const given = meta[OPERATION_PERIOD];
|
|
148
|
+
if (typeof given === "string") {
|
|
149
|
+
return next(method, input, { ...options, meta: sent });
|
|
150
|
+
}
|
|
151
|
+
// The period read goes through the same chain, without this call's
|
|
152
|
+
// context, and is shared by concurrent commands: no one caller's
|
|
153
|
+
// cancellation or deadline is given to it, it has its own.
|
|
154
|
+
const plain: RpcMetadata = { ...meta };
|
|
155
|
+
delete plain[OPERATION_KEY];
|
|
156
|
+
const { abort, timeout, ...shared } = options;
|
|
157
|
+
// The caller's deadline covers the wait for the period and the
|
|
158
|
+
// command together.
|
|
159
|
+
const deadline =
|
|
160
|
+
typeof timeout === "number" ? new Date(Date.now() + timeout) : timeout;
|
|
161
|
+
// The underlying call is held in a box: a UnaryCall is thenable, and
|
|
162
|
+
// a promise resolving to it would wait for its whole response.
|
|
163
|
+
const call = within(
|
|
164
|
+
period(
|
|
165
|
+
() =>
|
|
166
|
+
next(
|
|
167
|
+
OperationService.methods[0] as MethodInfo,
|
|
168
|
+
GetOperationPeriodRequest.create(),
|
|
169
|
+
{ ...shared, meta: plain, timeout: PERIOD_READ_TIMEOUT_MS },
|
|
170
|
+
).response as Promise<OperationPeriod>,
|
|
171
|
+
),
|
|
172
|
+
abort,
|
|
173
|
+
deadline,
|
|
174
|
+
).then((p) => {
|
|
175
|
+
sent[OPERATION_PERIOD] = p;
|
|
176
|
+
return {
|
|
177
|
+
inner: next(method, input, {
|
|
178
|
+
...options,
|
|
179
|
+
timeout: deadline,
|
|
180
|
+
meta: sent,
|
|
181
|
+
}),
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
// A caller awaits only the parts it needs; a failed period read fails
|
|
185
|
+
// each part without an unhandled rejection, as the transport's own do.
|
|
186
|
+
const part = <T>(pick: (c: Awaited<typeof call>) => Promise<T>) => {
|
|
187
|
+
const promise = call.then(pick);
|
|
188
|
+
promise.catch(() => {});
|
|
189
|
+
return promise;
|
|
190
|
+
};
|
|
191
|
+
return new UnaryCall(
|
|
192
|
+
method,
|
|
193
|
+
sent,
|
|
194
|
+
input,
|
|
195
|
+
part((c) => c.inner.headers),
|
|
196
|
+
part((c) => c.inner.response),
|
|
197
|
+
part((c) => c.inner.status),
|
|
198
|
+
part((c) => c.inner.trailers),
|
|
199
|
+
);
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport";
|
|
11
11
|
import type { RpcInterceptor, RpcTransport } from "@protobuf-ts/runtime-rpc";
|
|
12
12
|
import { bffCsrfToken } from "./useBffAuth";
|
|
13
|
+
import { operationContext } from "./operationContext";
|
|
13
14
|
|
|
14
15
|
/** Where the account API is reached. */
|
|
15
16
|
export interface AccountApiConfig {
|
|
@@ -49,7 +50,8 @@ export function initAccountApi(config: AccountApiConfig): void {
|
|
|
49
50
|
baseUrl: config.baseUrl,
|
|
50
51
|
// The BFF is on this origin; its cookie must not travel anywhere else.
|
|
51
52
|
fetchInit: { credentials: "same-origin" },
|
|
52
|
-
|
|
53
|
+
// The period read passes the CSRF interceptor too: it is the next link.
|
|
54
|
+
interceptors: [csrf, operationContext()],
|
|
53
55
|
});
|
|
54
56
|
}
|
|
55
57
|
|
|
@@ -28,7 +28,7 @@ describe("useGrpc", () => {
|
|
|
28
28
|
|
|
29
29
|
describe("initGrpc", () => {
|
|
30
30
|
// The BFF session cookie rides on every call, so credentials are always
|
|
31
|
-
// included;
|
|
31
|
+
// included; keyed calls carry an operation context.
|
|
32
32
|
it("creates GrpcWebFetchTransport with baseUrl and credentials", () => {
|
|
33
33
|
const config: GrpcConfig = { baseUrl: "https://api.sid.example.com" };
|
|
34
34
|
initGrpc(config);
|
|
@@ -36,6 +36,9 @@ describe("useGrpc", () => {
|
|
|
36
36
|
expect(GrpcWebFetchTransport).toHaveBeenCalledWith({
|
|
37
37
|
baseUrl: "https://api.sid.example.com",
|
|
38
38
|
fetchInit: { credentials: "include" },
|
|
39
|
+
interceptors: [
|
|
40
|
+
expect.objectContaining({ interceptUnary: expect.any(Function) }),
|
|
41
|
+
],
|
|
39
42
|
});
|
|
40
43
|
});
|
|
41
44
|
});
|
|
@@ -2,6 +2,7 @@ import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport";
|
|
|
2
2
|
import type { RpcTransport } from "@protobuf-ts/runtime-rpc";
|
|
3
3
|
import { ref, type Ref } from "vue";
|
|
4
4
|
import type { GrpcConfig } from "../types/auth";
|
|
5
|
+
import { operationContext } from "./operationContext";
|
|
5
6
|
|
|
6
7
|
/** Shared gRPC-web transport instance. */
|
|
7
8
|
let _transport: RpcTransport | null = null;
|
|
@@ -23,6 +24,7 @@ export function initGrpc(config: GrpcConfig): void {
|
|
|
23
24
|
// The BFF proxy exchanges the session cookie for a Bearer token
|
|
24
25
|
// server-side.
|
|
25
26
|
fetchInit: { credentials: "include" },
|
|
27
|
+
interceptors: [operationContext()],
|
|
26
28
|
});
|
|
27
29
|
}
|
|
28
30
|
|