@procedures/client 0.52.3 → 0.53.1
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/CHANGELOG.md +46 -0
- package/README.md +11 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/server/cors.d.ts.map +1 -1
- package/dist/adapters/http/server/cors.js +2 -0
- package/dist/adapters/http/server/cors.js.map +1 -1
- package/dist/adapters/port/transport.d.ts +27 -0
- package/dist/adapters/port/transport.d.ts.map +1 -1
- package/dist/adapters/port/transport.js +30 -0
- package/dist/adapters/port/transport.js.map +1 -1
- package/dist/browser.d.ts +2 -2
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/client/batch-executor.d.ts +3 -0
- package/dist/client/batch-executor.d.ts.map +1 -1
- package/dist/client/batch-executor.js +3 -0
- package/dist/client/batch-executor.js.map +1 -1
- package/dist/client/client.d.ts +2 -0
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +33 -5
- package/dist/client/client.js.map +1 -1
- package/dist/client/middleware/cache.d.ts.map +1 -1
- package/dist/client/middleware/cache.js +2 -0
- package/dist/client/middleware/cache.js.map +1 -1
- package/dist/client/middleware/rate-limit.d.ts.map +1 -1
- package/dist/client/middleware/rate-limit.js +3 -2
- package/dist/client/middleware/rate-limit.js.map +1 -1
- package/dist/procedures/interceptors.d.ts +10 -1
- package/dist/procedures/interceptors.d.ts.map +1 -1
- package/dist/procedures/interceptors.js +31 -8
- package/dist/procedures/interceptors.js.map +1 -1
- package/dist/server/host-policy.d.ts +8 -0
- package/dist/server/host-policy.d.ts.map +1 -1
- package/dist/server/host-policy.js +3 -0
- package/dist/server/host-policy.js.map +1 -1
- package/dist/server/limits.d.ts +1 -0
- package/dist/server/limits.d.ts.map +1 -1
- package/dist/server/limits.js +13 -0
- package/dist/server/limits.js.map +1 -1
- package/dist/server/procedure-server.d.ts +21 -0
- package/dist/server/procedure-server.d.ts.map +1 -1
- package/dist/server/procedure-server.js +35 -3
- package/dist/server/procedure-server.js.map +1 -1
- package/dist/tracing/otel.d.ts +55 -0
- package/dist/tracing/otel.d.ts.map +1 -0
- package/dist/tracing/otel.js +114 -0
- package/dist/tracing/otel.js.map +1 -0
- package/dist/tracing/traceparent.d.ts +40 -12
- package/dist/tracing/traceparent.d.ts.map +1 -1
- package/dist/tracing/traceparent.js +49 -14
- package/dist/tracing/traceparent.js.map +1 -1
- package/package.json +15 -5
- package/src/adapters/http/server/cors.ts +2 -0
- package/src/adapters/port/transport.ts +44 -0
- package/src/browser.ts +2 -2
- package/src/client/batch-executor.ts +3 -0
- package/src/client/client.ts +31 -5
- package/src/client/middleware/cache.ts +2 -0
- package/src/client/middleware/rate-limit.ts +4 -2
- package/src/procedures/interceptors.ts +40 -9
- package/src/server/host-policy.ts +11 -0
- package/src/server/limits.ts +13 -0
- package/src/server/procedure-server.ts +46 -4
- package/src/tracing/otel.ts +122 -0
- package/src/tracing/traceparent.ts +56 -15
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"traceparent.js","sourceRoot":"","sources":["../../src/tracing/traceparent.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"traceparent.js","sourceRoot":"","sources":["../../src/tracing/traceparent.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package",
|
|
3
3
|
"name": "@procedures/client",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.53.1",
|
|
5
5
|
"description": "Procedures as data for TypeScript: define typed procedures once and call them in process, over HTTP, WebSocket, the CLI or MCP. Streaming, middleware, retries, caching and a JSON program format.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"rpc",
|
|
@@ -112,19 +112,29 @@
|
|
|
112
112
|
},
|
|
113
113
|
"dependencies": {
|
|
114
114
|
"@mark1russell7/algebra": "^0.8.0",
|
|
115
|
-
"@mark1russell7/coeval": "^0.
|
|
115
|
+
"@mark1russell7/coeval": "^0.26.1",
|
|
116
116
|
"@mark1russell7/collections": "^0.5.0",
|
|
117
117
|
"@mark1russell7/quantity": "^0.3.0",
|
|
118
|
-
"@mark1russell7/service": "^0.
|
|
118
|
+
"@mark1russell7/service": "^0.8.3",
|
|
119
|
+
"@opentelemetry/api": "^1.9.0",
|
|
119
120
|
"@types/ws": "^8.18.1",
|
|
120
121
|
"ws": "^8.18.3",
|
|
121
122
|
"zod": "^4.6.5"
|
|
122
123
|
},
|
|
123
124
|
"devDependencies": {
|
|
124
|
-
"@mark1russell7/api-report": "^0.
|
|
125
|
+
"@mark1russell7/api-report": "^0.5.0",
|
|
125
126
|
"@mark1russell7/cue": "^2.1.1",
|
|
126
|
-
"@mark1russell7/
|
|
127
|
+
"@mark1russell7/otel-ts": "^0.6.0",
|
|
128
|
+
"@mark1russell7/rigor-bench": "^0.2.33",
|
|
127
129
|
"@mark1russell7/rigor-statistics": "^0.1.9",
|
|
130
|
+
"@mark1russell7/rigor-testkit": "^0.1.2",
|
|
131
|
+
"@opentelemetry/api-logs": "^0.223.0",
|
|
132
|
+
"@opentelemetry/context-async-hooks": "^2.12.0",
|
|
133
|
+
"@opentelemetry/core": "^2.12.0",
|
|
134
|
+
"@opentelemetry/sdk-logs": "^0.223.0",
|
|
135
|
+
"@opentelemetry/sdk-metrics": "^2.12.0",
|
|
136
|
+
"@opentelemetry/sdk-node": "~0.223.0",
|
|
137
|
+
"@opentelemetry/sdk-trace-base": "^2.12.0",
|
|
128
138
|
"@stryker-mutator/core": "10.0.0",
|
|
129
139
|
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
130
140
|
"@types/express": "^5.0.0",
|
|
@@ -50,6 +50,8 @@ export const DEFAULT_CORS_HEADERS: readonly string[] = Object.freeze([
|
|
|
50
50
|
HTTPHeaders.SPAN_ID,
|
|
51
51
|
HTTPHeaders.PARENT_SPAN_ID,
|
|
52
52
|
"traceparent",
|
|
53
|
+
"tracestate",
|
|
54
|
+
"baggage",
|
|
53
55
|
PROTOCOL_HEADER,
|
|
54
56
|
HTTPHeaders.METADATA,
|
|
55
57
|
// The narrowing key of a call (CLI-30)
|
|
@@ -45,6 +45,50 @@ export interface PortLike {
|
|
|
45
45
|
start?(): void;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** The listener of a port of the main process of Electron: it gets an event, and the message is its `data`. */
|
|
49
|
+
type MainListener = (event: { readonly data: unknown }) => void;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A port of the main process of Electron (`MessagePortMain`). The listener of its `on` gets an event with the message
|
|
53
|
+
* in `data`. The Node form of `PortLike` gets the message itself, so `electronPortOf` changes the form.
|
|
54
|
+
*/
|
|
55
|
+
export interface MainPortLike {
|
|
56
|
+
postMessage(message: unknown): void;
|
|
57
|
+
on(type: "message", listener: MainListener): unknown;
|
|
58
|
+
off?(type: "message", listener: MainListener): unknown;
|
|
59
|
+
start(): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* This function gives the `PortLike` of a port of the main process of Electron. Each listener gets the `data` of the
|
|
64
|
+
* event. `off` removes the listener that `on` added, so the close of a `PortTransport` stops to listen.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```typescript
|
|
68
|
+
* const { port1, port2 } = new MessageChannelMain();
|
|
69
|
+
* new PortTransport(electronPortOf(port1), { serve: server });
|
|
70
|
+
* window.webContents.postMessage("ports", null, [port2]);
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export function electronPortOf(port: MainPortLike): PortLike {
|
|
74
|
+
const listeners = new Map<(data: unknown) => void, MainListener>();
|
|
75
|
+
return {
|
|
76
|
+
postMessage: (message) => port.postMessage(message),
|
|
77
|
+
on(type, listener) {
|
|
78
|
+
const each: MainListener = (event) => listener(event.data);
|
|
79
|
+
listeners.set(listener, each);
|
|
80
|
+
return port.on(type, each);
|
|
81
|
+
},
|
|
82
|
+
off(type, listener) {
|
|
83
|
+
const each = listeners.get(listener);
|
|
84
|
+
if (each === undefined) return undefined;
|
|
85
|
+
listeners.delete(listener);
|
|
86
|
+
return port.off?.(type, each);
|
|
87
|
+
},
|
|
88
|
+
start: () => port.start(),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
48
92
|
/** The options of a `PortTransport`. */
|
|
49
93
|
export interface PortTransportOptions {
|
|
50
94
|
/** The handler of the calls of the other side, for example a `ProcedureServer`. */
|
package/src/browser.ts
CHANGED
|
@@ -469,8 +469,8 @@ export type { MachineDocument, MachineTransition, MachineTransitionDef, MachineT
|
|
|
469
469
|
export { TIMER_DEFAULTS } from "./timers.js";
|
|
470
470
|
export type { TimerDefaults } from "./timers.js";
|
|
471
471
|
// The transport over a message port: a Web Worker, a MessageChannel (roadmap R27)
|
|
472
|
-
export { PortTransport } from "./adapters/port/transport.js";
|
|
473
|
-
export type { PortLike, PortTransportOptions } from "./adapters/port/transport.js";
|
|
472
|
+
export { PortTransport, electronPortOf } from "./adapters/port/transport.js";
|
|
473
|
+
export type { MainPortLike, PortLike, PortTransportOptions } from "./adapters/port/transport.js";
|
|
474
474
|
// The W3C trace context: one trace from the client to the handler (roadmap R16)
|
|
475
475
|
export { TRACEPARENT_METADATA_KEY, formatTraceparent, parseTraceparent, newTraceId, newSpanId, isW3cIds } from "./tracing/traceparent.js";
|
|
476
476
|
export type { TraceContext } from "./tracing/traceparent.js";
|
|
@@ -460,6 +460,9 @@ export function determineStrategy(
|
|
|
460
460
|
|
|
461
461
|
/**
|
|
462
462
|
* Semaphore for limiting concurrent executions.
|
|
463
|
+
*
|
|
464
|
+
* @deprecated Use `semaphore()` of `@mark1russell7/collections`, the home of the semaphore. Its `run(task)` replaces
|
|
465
|
+
* `withPermit(fn)`, and its `acquire()` gives the release function. The next MINOR break of `@procedures/client` removes this class.
|
|
463
466
|
*/
|
|
464
467
|
export class Semaphore {
|
|
465
468
|
private permits: number;
|
package/src/client/client.ts
CHANGED
|
@@ -70,6 +70,8 @@ import {
|
|
|
70
70
|
import { narrowCapability } from "../procedures/capability.js";
|
|
71
71
|
import { DEADLINE_METADATA_KEY, TIMEOUT_METADATA_KEY, absorbTimeout, writeRemainingTime } from "./deadline.js";
|
|
72
72
|
import { ms } from "../duration.js";
|
|
73
|
+
import { context as otelContext, SpanKind, trace } from "@opentelemetry/api";
|
|
74
|
+
import { asInternalCall, callParent, callerTraceOf, endApiSpan, injectContext, startApiSpan, type CallerTrace } from "../tracing/otel.js";
|
|
73
75
|
import type { Capability, ClearanceOrder, NestedCallOptions } from "../procedures/types.js";
|
|
74
76
|
import { PROCEDURE_REGISTRY } from "../procedures/registry.js";
|
|
75
77
|
import { validateSchema } from "../procedures/schema.js";
|
|
@@ -825,8 +827,9 @@ export class Client<TContext = {}> {
|
|
|
825
827
|
// remaining time again for each try (roadmap R10)
|
|
826
828
|
absorbTimeout(message.metadata);
|
|
827
829
|
|
|
828
|
-
// Compose middleware chain
|
|
829
|
-
|
|
830
|
+
// Compose middleware chain. The trace context that the caller gave in the metadata is read
|
|
831
|
+
// before a middleware or a try writes its own (roadmap C19 of otel-ts).
|
|
832
|
+
const runner = this.composeMiddleware<TReq, TRes>(callerTraceOf(message.metadata as Record<string, unknown>));
|
|
830
833
|
|
|
831
834
|
// Execute through middleware chain
|
|
832
835
|
const context: ClientContext<TReq> = { message };
|
|
@@ -897,7 +900,7 @@ export class Client<TContext = {}> {
|
|
|
897
900
|
metadata["timeout"] = NO_SUBSCRIPTION_TIMEOUT as unknown as NonNullable<Metadata["timeout"]>;
|
|
898
901
|
Object.assign(metadata, message.metadata);
|
|
899
902
|
absorbTimeout(metadata);
|
|
900
|
-
return this.composeMiddleware<TReq, TRes>()({ message: { ...message, metadata } });
|
|
903
|
+
return this.composeMiddleware<TReq, TRes>(callerTraceOf(metadata as Record<string, unknown>))({ message: { ...message, metadata } });
|
|
901
904
|
};
|
|
902
905
|
this.subscriptionTransport = { send };
|
|
903
906
|
}
|
|
@@ -907,17 +910,40 @@ export class Client<TContext = {}> {
|
|
|
907
910
|
/**
|
|
908
911
|
* Compose middleware chain using universal middleware composition.
|
|
909
912
|
*
|
|
913
|
+
* @param caller - The trace context that the caller gave in the metadata of the call. It is the
|
|
914
|
+
* parent of the span of each try when no span is active (roadmap C19 of otel-ts).
|
|
910
915
|
* @returns Composed runner function
|
|
911
916
|
* @private
|
|
912
917
|
*/
|
|
913
|
-
private composeMiddleware<TReq, TRes>(): ClientRunner<TReq, TRes> {
|
|
918
|
+
private composeMiddleware<TReq, TRes>(caller: CallerTrace): ClientRunner<TReq, TRes> {
|
|
914
919
|
// Innermost function: call transport
|
|
915
920
|
const self = this;
|
|
916
921
|
const transportRunner: ClientRunner<TReq, TRes> = async function* (context: ClientContext<TReq>) {
|
|
917
922
|
// The remaining time of the deadline goes with each try, so the server stops the handler
|
|
918
923
|
// when this client stops waiting (roadmap R10)
|
|
919
924
|
writeRemainingTime(context.message.metadata);
|
|
920
|
-
|
|
925
|
+
// With an SDK of OpenTelemetry, each try is a span of the API, and the propagator writes its
|
|
926
|
+
// context into the metadata, with the mark of an internal call (roadmap C8 of otel-ts)
|
|
927
|
+
const { metadata, method } = context.message;
|
|
928
|
+
const active = callParent(caller, otelContext.active());
|
|
929
|
+
const span = startApiSpan(`${method.service}.${method.operation}`, SpanKind.CLIENT, active);
|
|
930
|
+
if (span === undefined) {
|
|
931
|
+
yield* self.transport.send<TReq, TRes>(context.message);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const own = span.spanContext();
|
|
935
|
+
const parent = trace.getSpanContext(active);
|
|
936
|
+
metadata.tracing = { traceId: own.traceId, spanId: own.spanId, ...(parent ? { parentSpanId: parent.spanId } : {}) };
|
|
937
|
+
injectContext(asInternalCall(trace.setSpan(active, span)), metadata as Record<string, unknown>);
|
|
938
|
+
let failure: unknown;
|
|
939
|
+
try {
|
|
940
|
+
yield* self.transport.send<TReq, TRes>(context.message);
|
|
941
|
+
} catch (error) {
|
|
942
|
+
failure = error;
|
|
943
|
+
throw error;
|
|
944
|
+
} finally {
|
|
945
|
+
endApiSpan(span, failure);
|
|
946
|
+
}
|
|
921
947
|
};
|
|
922
948
|
|
|
923
949
|
// The first middleware is the outermost one (onion model)
|
|
@@ -308,8 +308,10 @@ export function createRateLimitMiddleware(
|
|
|
308
308
|
while (queue.length > 0) {
|
|
309
309
|
const decision = take();
|
|
310
310
|
if (!decision.allowed) {
|
|
311
|
-
// The time until the next whole token
|
|
312
|
-
|
|
311
|
+
// The time until the next whole token. A take of one unit is never over a burst of 1 or more,
|
|
312
|
+
// so a capacity refusal does not occur here. If it occurs, the queue waits one window.
|
|
313
|
+
const wait =
|
|
314
|
+
decision.cause === "capacity" ? Math.max(1, window) : Math.max(1, Math.ceil(valueIn(decision.retryAfter, "ms")));
|
|
313
315
|
drainTimer = clock.after(wait, () => {
|
|
314
316
|
drainTimer = undefined;
|
|
315
317
|
scheduleDrain();
|
|
@@ -10,6 +10,8 @@ import { InvocationError, closableUnread, type Interceptor, type Invocation, typ
|
|
|
10
10
|
import { pathToKey, type ProcedurePath } from "./types.js";
|
|
11
11
|
import { toMs, type Duration } from "../duration.js";
|
|
12
12
|
import { TRACEPARENT_METADATA_KEY, formatTraceparent, newSpanId, newTraceId, parseTraceparent } from "../tracing/traceparent.js";
|
|
13
|
+
import { context as otelContext, isSpanContextValid, SpanKind, trace, type Span } from "@opentelemetry/api";
|
|
14
|
+
import { endApiSpan, remoteParent, startApiSpan } from "../tracing/otel.js";
|
|
13
15
|
|
|
14
16
|
// =============================================================================
|
|
15
17
|
// Timeout
|
|
@@ -134,7 +136,10 @@ export interface InvocationSpan {
|
|
|
134
136
|
* remote caller, when the caller sent a `traceparent` (roadmap R16), or none.
|
|
135
137
|
*/
|
|
136
138
|
readonly parentId: string | undefined;
|
|
137
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* True when the parent span came from the `traceparent` of the call: the caller of a host. With an
|
|
141
|
+
* SDK of OpenTelemetry, a local call of a client also has a remote parent: the span of its client.
|
|
142
|
+
*/
|
|
138
143
|
readonly remoteParent?: boolean | undefined;
|
|
139
144
|
/** The same for every span of one call of a host, and of its caller when it sent a trace. */
|
|
140
145
|
readonly traceId: string;
|
|
@@ -162,6 +167,12 @@ export interface TracingInterceptorOptions {
|
|
|
162
167
|
onStart?: ((span: InvocationSpan) => void) | undefined;
|
|
163
168
|
/** The clock. The default is `performance.now()`. */
|
|
164
169
|
now?: (() => number) | undefined;
|
|
170
|
+
/**
|
|
171
|
+
* With an SDK of OpenTelemetry, each invocation is also a span of the API, and the span ids come
|
|
172
|
+
* from it: a host span (`SERVER`) for a call of a host, an `INTERNAL` span for a nested call.
|
|
173
|
+
* The handler runs in the context of its span. Default: true. With no SDK, this option does nothing.
|
|
174
|
+
*/
|
|
175
|
+
apiSpans?: boolean | undefined;
|
|
165
176
|
}
|
|
166
177
|
|
|
167
178
|
|
|
@@ -173,6 +184,8 @@ export function tracingInterceptor(options: TracingInterceptorOptions): Intercep
|
|
|
173
184
|
const now = options.now ?? (() => performance.now());
|
|
174
185
|
/** The open spans, by the id of their invocation */
|
|
175
186
|
const open = new Map<number, InvocationSpan>();
|
|
187
|
+
/** The open spans of the API, by the id of their invocation */
|
|
188
|
+
const apiOpen = new Map<number, Span>();
|
|
176
189
|
|
|
177
190
|
const finish = (span: InvocationSpan, invocationId: number, error?: unknown): void => {
|
|
178
191
|
span.end = now();
|
|
@@ -186,22 +199,35 @@ export function tracingInterceptor(options: TracingInterceptorOptions): Intercep
|
|
|
186
199
|
span.status = "ok";
|
|
187
200
|
}
|
|
188
201
|
open.delete(invocationId);
|
|
202
|
+
const apiSpan = apiOpen.get(invocationId);
|
|
203
|
+
if (apiSpan !== undefined) {
|
|
204
|
+
apiOpen.delete(invocationId);
|
|
205
|
+
endApiSpan(apiSpan, error);
|
|
206
|
+
}
|
|
189
207
|
options.onSpan(span);
|
|
190
208
|
};
|
|
191
209
|
|
|
192
210
|
return async (invocation, next) => {
|
|
193
211
|
const parent = invocation.parent ? open.get(invocation.parent.id) : undefined;
|
|
194
|
-
|
|
195
|
-
|
|
212
|
+
const key = pathToKey(invocation.path);
|
|
213
|
+
// With an SDK, the span of the API gives the ids (roadmap C8 of otel-ts, D5). The parent is the
|
|
214
|
+
// span of the parent invocation, else the span of the traceparent of the call.
|
|
215
|
+
const parentApi = invocation.parent ? apiOpen.get(invocation.parent.id) : undefined;
|
|
216
|
+
const apiParent = parentApi !== undefined ? trace.setSpan(otelContext.active(), parentApi) : remoteParent(invocation.metadata);
|
|
217
|
+
const apiSpan = options.apiSpans === false ? undefined : startApiSpan(key, invocation.parent ? SpanKind.INTERNAL : SpanKind.SERVER, apiParent);
|
|
218
|
+
const apiParentSpan = apiSpan === undefined ? undefined : trace.getSpanContext(apiParent);
|
|
219
|
+
// With no SDK: a call of a host continues the trace of its caller (W3C traceparent, roadmap R16)
|
|
220
|
+
const remote = parent || apiSpan ? undefined : parseTraceparent(invocation.metadata[TRACEPARENT_METADATA_KEY]);
|
|
196
221
|
// W3C span ids, so a trace backend can join the spans of the client and of the server
|
|
197
|
-
const id = newSpanId();
|
|
222
|
+
const id = apiSpan?.spanContext().spanId ?? newSpanId();
|
|
223
|
+
const apiParentId = apiParentSpan !== undefined && isSpanContextValid(apiParentSpan) ? apiParentSpan.spanId : undefined;
|
|
198
224
|
const span: InvocationSpan = {
|
|
199
225
|
id,
|
|
200
|
-
parentId: parent?.id ?? remote?.spanId,
|
|
201
|
-
...(remote ? { remoteParent: true } : {}),
|
|
202
|
-
traceId: parent?.traceId ?? remote?.traceId ?? newTraceId(),
|
|
226
|
+
parentId: apiSpan ? apiParentId : (parent?.id ?? remote?.spanId),
|
|
227
|
+
...(remote || (apiParentId !== undefined && apiParentSpan?.isRemote === true) ? { remoteParent: true } : {}),
|
|
228
|
+
traceId: apiSpan?.spanContext().traceId ?? parent?.traceId ?? remote?.traceId ?? newTraceId(),
|
|
203
229
|
path: invocation.path,
|
|
204
|
-
key
|
|
230
|
+
key,
|
|
205
231
|
depth: invocation.depth,
|
|
206
232
|
start: now(),
|
|
207
233
|
end: undefined,
|
|
@@ -209,12 +235,16 @@ export function tracingInterceptor(options: TracingInterceptorOptions): Intercep
|
|
|
209
235
|
status: "running",
|
|
210
236
|
};
|
|
211
237
|
open.set(invocation.id, span);
|
|
238
|
+
if (apiSpan !== undefined) apiOpen.set(invocation.id, apiSpan);
|
|
212
239
|
options.onStart?.(span);
|
|
213
240
|
|
|
214
241
|
let output: ProcedureOutput<unknown>;
|
|
215
242
|
try {
|
|
216
243
|
// The nested calls (and their remote calls) carry the span of this invocation (R16)
|
|
217
|
-
|
|
244
|
+
const sampled = apiSpan === undefined || (apiSpan.spanContext().traceFlags & 1) === 1;
|
|
245
|
+
const run = () => next({ metadata: { ...invocation.metadata, [TRACEPARENT_METADATA_KEY]: formatTraceparent(span.traceId, id, sampled) } });
|
|
246
|
+
// The handler runs in the context of its span, so its logs, metrics and calls get the ids
|
|
247
|
+
output = await (apiSpan === undefined ? run() : otelContext.with(trace.setSpan(apiParent, apiSpan), run));
|
|
218
248
|
} catch (error) {
|
|
219
249
|
finish(span, invocation.id, error);
|
|
220
250
|
throw error;
|
|
@@ -286,6 +316,7 @@ export function metricsInterceptor(options: { now?: () => number } = {}): Metric
|
|
|
286
316
|
};
|
|
287
317
|
return {
|
|
288
318
|
interceptor: tracingInterceptor({
|
|
319
|
+
apiSpans: false,
|
|
289
320
|
onSpan: (span) => record(span.key, span.durationMs ?? 0, span.status === "error"),
|
|
290
321
|
...(options.now ? { now: options.now } : {}),
|
|
291
322
|
}),
|
|
@@ -96,6 +96,12 @@ export interface HostPolicyOptions {
|
|
|
96
96
|
* query) and gives a bearer token, a principal and a narrowing (`ProcedureServerOptions`).
|
|
97
97
|
*/
|
|
98
98
|
principalOf?: PrincipalOf | undefined;
|
|
99
|
+
/**
|
|
100
|
+
* The callers whose `baggage` the handler gets (C10), more than a local request and a request
|
|
101
|
+
* with the token. The function runs after the checks of the request, so it can read
|
|
102
|
+
* `request.principal`. Without it, no other caller sets the baggage of the services.
|
|
103
|
+
*/
|
|
104
|
+
trustBaggage?: ((request: ServerRequest<unknown>) => boolean) | undefined;
|
|
99
105
|
/** The browser checks of the network transports (`allowedOrigins`, `allowedHosts`, `allowGet`). */
|
|
100
106
|
browser?: BrowserGuardOptions | undefined;
|
|
101
107
|
/**
|
|
@@ -137,6 +143,8 @@ export interface HostPolicy {
|
|
|
137
143
|
readonly token: string | undefined;
|
|
138
144
|
/** The request hook of the network requests, or undefined for none. */
|
|
139
145
|
readonly principalOf?: PrincipalOf | undefined;
|
|
146
|
+
/** The callers whose baggage the handler gets (C10), or undefined for none more. */
|
|
147
|
+
readonly trustBaggage?: ((request: ServerRequest<unknown>) => boolean) | undefined;
|
|
140
148
|
readonly browser: BrowserGuardOptions;
|
|
141
149
|
/** The capability of a call from a caller, or undefined for no limits. */
|
|
142
150
|
readonly capabilityOf: ((request?: ServerRequest<unknown>) => Capability | undefined) | undefined;
|
|
@@ -190,6 +198,7 @@ export function hostPolicy(options: HostPolicyOptions = {}): HostPolicy {
|
|
|
190
198
|
interceptors,
|
|
191
199
|
token: options.token,
|
|
192
200
|
...(options.principalOf ? { principalOf: options.principalOf } : {}),
|
|
201
|
+
...(options.trustBaggage ? { trustBaggage: options.trustBaggage } : {}),
|
|
193
202
|
browser: options.browser ?? {},
|
|
194
203
|
capabilityOf: capabilityOfOptions(options),
|
|
195
204
|
clearanceOrder: options.clearanceOrder,
|
|
@@ -212,6 +221,8 @@ export function procedureServerOptions(policy: HostPolicy, registry?: ProcedureR
|
|
|
212
221
|
...(policy.token !== undefined ? { token: policy.token } : {}),
|
|
213
222
|
// The request hook of the policy: the credentials of a cookie, a header or the query (CLI-2a)
|
|
214
223
|
...(policy.principalOf ? { principalOf: policy.principalOf } : {}),
|
|
224
|
+
// The callers whose baggage the handler gets (C10)
|
|
225
|
+
...(policy.trustBaggage ? { trustBaggage: policy.trustBaggage } : {}),
|
|
215
226
|
...(policy.interceptors.length > 0 ? { interceptors: policy.interceptors } : {}),
|
|
216
227
|
...(policy.capabilityOf ? { capability: policy.capabilityOf } : {}),
|
|
217
228
|
...(policy.clearanceOrder ? { clearanceOrder: policy.clearanceOrder } : {}),
|
package/src/server/limits.ts
CHANGED
|
@@ -236,9 +236,22 @@ export function limitedCall(procedure: { readonly path: ProcedurePath; readonly
|
|
|
236
236
|
/**
|
|
237
237
|
* This function gives the response of a refused call: `RATE_LIMITED`, retryable, with the wait in
|
|
238
238
|
* `retry-after` (milliseconds) and the cause in `reason`. The failure of a store stays on the server.
|
|
239
|
+
* A refusal with the cause `capacity` is not retryable and has no wait: no wait lets its units in.
|
|
239
240
|
*/
|
|
240
241
|
export function rateLimitedResponse(id: string, refusal: CallLimitRefusal): ServerResponse<unknown> {
|
|
241
242
|
const { limit, refused } = refusal;
|
|
243
|
+
if (refused.cause === "capacity") {
|
|
244
|
+
return {
|
|
245
|
+
id,
|
|
246
|
+
status: {
|
|
247
|
+
type: "error",
|
|
248
|
+
code: "RATE_LIMITED",
|
|
249
|
+
message: `The limit ${limit} of the host refuses the call at each time: ${refused.units} units are over its capacity of ${refused.capacity}`,
|
|
250
|
+
retryable: false,
|
|
251
|
+
},
|
|
252
|
+
metadata: { reason: refused.cause },
|
|
253
|
+
};
|
|
254
|
+
}
|
|
242
255
|
const waitMs = valueIn(refused.retryAfter, "ms");
|
|
243
256
|
const seconds = retryAfterSeconds(refused.retryAfter);
|
|
244
257
|
return {
|
|
@@ -13,6 +13,8 @@ import type { Admission, AmbientCredentials, Credentials, Method, Principal, Pri
|
|
|
13
13
|
import { methodName, methodToPath, pathToMethod } from "./method.js";
|
|
14
14
|
import { isLocalCredentials } from "./request-auth.js";
|
|
15
15
|
import { STREAM_METADATA_KEY } from "./stream-metadata.js";
|
|
16
|
+
import { context as otelContext } from "@opentelemetry/api";
|
|
17
|
+
import { callerContext } from "../tracing/otel.js";
|
|
16
18
|
import type {
|
|
17
19
|
AnyProcedure,
|
|
18
20
|
ProcedurePath,
|
|
@@ -151,6 +153,20 @@ export interface ProcedureServerOptions extends ServerOptions {
|
|
|
151
153
|
*/
|
|
152
154
|
token?: string | undefined;
|
|
153
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Which callers can set the baggage of a call (roadmap item C10 of otel-ts). Each handler runs in
|
|
158
|
+
* the context of its caller: the remote span of the `traceparent` and `tracestate` of the
|
|
159
|
+
* metadata is its parent. The `baggage` of the metadata goes into the context only from a
|
|
160
|
+
* trusted caller. Without it, the handler runs with no baggage, so an end user cannot set the
|
|
161
|
+
* baggage of the services.
|
|
162
|
+
*
|
|
163
|
+
* A local request (`LOCAL_CREDENTIALS`) and a request with the token of the server are always
|
|
164
|
+
* trusted. This function can trust more requests, for example the requests of a principal of a
|
|
165
|
+
* service. It runs after the checks of the request, so it can read `request.principal`. An
|
|
166
|
+
* error of the function means "not trusted". The default trusts no other request.
|
|
167
|
+
*/
|
|
168
|
+
trustBaggage?: ((request: ServerRequest<unknown>) => boolean) | undefined;
|
|
169
|
+
|
|
154
170
|
/**
|
|
155
171
|
* This function gives the principal of a request, or undefined to refuse it with
|
|
156
172
|
* `UNAUTHORIZED`. It runs after the token check. The capability function can read
|
|
@@ -455,11 +471,17 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
455
471
|
private readonly token: string | undefined;
|
|
456
472
|
private readonly principalOf: PrincipalOf | undefined;
|
|
457
473
|
private readonly authenticate: ProcedureServerOptions["authenticate"];
|
|
474
|
+
private readonly trustBaggage: ProcedureServerOptions["trustBaggage"];
|
|
475
|
+
/**
|
|
476
|
+
* The requests whose caller can set the baggage (C10): a local request, a request with the
|
|
477
|
+
* token of the server, or a request with the admission of such a request.
|
|
478
|
+
*/
|
|
479
|
+
private readonly trustedCallers = new WeakSet<ServerRequest<unknown>>();
|
|
458
480
|
/**
|
|
459
481
|
* The admissions of this server (CLI-2b): the identity of each object of admitted credentials
|
|
460
482
|
* that `admission()` gave. Only this server adds an entry, so a request cannot claim one.
|
|
461
483
|
*/
|
|
462
|
-
private readonly admissions = new WeakMap<Credentials, { readonly principal: Principal | undefined; readonly narrowing: ServerRequest<unknown>["narrowing"] }>();
|
|
484
|
+
private readonly admissions = new WeakMap<Credentials, { readonly principal: Principal | undefined; readonly narrowing: ServerRequest<unknown>["narrowing"]; readonly trusted: boolean }>();
|
|
463
485
|
/**
|
|
464
486
|
* The admission of each request object. The late lookup of a lazy registry admits a request
|
|
465
487
|
* before it loads packages, and the call of the same request uses that result again.
|
|
@@ -497,6 +519,7 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
497
519
|
this.token = options.token;
|
|
498
520
|
this.principalOf = options.principalOf;
|
|
499
521
|
this.authenticate = options.authenticate;
|
|
522
|
+
this.trustBaggage = options.trustBaggage;
|
|
500
523
|
const ambient = options.ambientCredentials ?? (this.principalOf ? "always" : this.authenticate ? "cookies" : undefined);
|
|
501
524
|
this.ambientCredentials = ambient === false ? undefined : ambient;
|
|
502
525
|
this.debugReport =
|
|
@@ -937,6 +960,7 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
937
960
|
// again. Only this server makes such credentials, so a copy or a forgery has no entry.
|
|
938
961
|
const admitted = request.credentials !== undefined ? this.admissions.get(request.credentials) : undefined;
|
|
939
962
|
if (admitted) {
|
|
963
|
+
if (admitted.trusted) this.trustedCallers.add(request);
|
|
940
964
|
if (admitted.principal) request.principal = admitted.principal;
|
|
941
965
|
if (admitted.narrowing) {
|
|
942
966
|
const order = this.capabilityOptions.clearanceOrder;
|
|
@@ -953,6 +977,8 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
953
977
|
// A local face (this process, MCP over stdio, a message port) sets the local marker. The
|
|
954
978
|
// marker key is a symbol, so a request from the network cannot carry it (CLI-1).
|
|
955
979
|
const credentials = isLocalCredentials(request.credentials) ? undefined : request.credentials;
|
|
980
|
+
// A local caller is a part of this process: it can set the baggage (C10)
|
|
981
|
+
if (isLocalCredentials(request.credentials)) this.trustedCallers.add(request);
|
|
956
982
|
let identity: RequestIdentity | undefined;
|
|
957
983
|
if (this.principalOf && credentials !== undefined) {
|
|
958
984
|
try {
|
|
@@ -966,6 +992,8 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
966
992
|
const bearer = identity?.bearer ?? credentials.bearer;
|
|
967
993
|
if (!tokenMatches(this.token, bearer)) return unauthorized("Unauthorized: this server requires its token");
|
|
968
994
|
request.principal = { id: "token", via: "token" };
|
|
995
|
+
// A caller with the token of the server is a service of the operator: it can set the baggage (C10)
|
|
996
|
+
this.trustedCallers.add(request);
|
|
969
997
|
}
|
|
970
998
|
if (identity?.principal) request.principal = identity.principal;
|
|
971
999
|
if (identity?.narrowing) {
|
|
@@ -1063,7 +1091,7 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
1063
1091
|
return { ok: false, code: "UNAUTHORIZED", message: refusal.status.type === "error" ? refusal.status.message : "Unauthorized" };
|
|
1064
1092
|
}
|
|
1065
1093
|
const admitted: Credentials = Object.freeze({ ...credentials });
|
|
1066
|
-
this.admissions.set(admitted, { principal: request.principal, narrowing: request.narrowing });
|
|
1094
|
+
this.admissions.set(admitted, { principal: request.principal, narrowing: request.narrowing, trusted: this.trustedCallers.has(request) });
|
|
1067
1095
|
return { ok: true, credentials: admitted, ...(request.principal ? { principal: request.principal } : {}) };
|
|
1068
1096
|
}
|
|
1069
1097
|
|
|
@@ -1144,6 +1172,17 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
1144
1172
|
};
|
|
1145
1173
|
}
|
|
1146
1174
|
|
|
1175
|
+
/** True when the caller of `request` can set the baggage of the call (C10). */
|
|
1176
|
+
private trustsCaller(request: ServerRequest<unknown>): boolean {
|
|
1177
|
+
if (this.trustedCallers.has(request)) return true;
|
|
1178
|
+
if (!this.trustBaggage) return false;
|
|
1179
|
+
try {
|
|
1180
|
+
return this.trustBaggage(request) === true;
|
|
1181
|
+
} catch {
|
|
1182
|
+
return false;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1147
1186
|
/** Run a procedure for a request with a signal. */
|
|
1148
1187
|
private async run(procedure: AnyProcedure, request: ServerRequest<unknown>, signal: AbortSignal | undefined): Promise<ServerResponse<unknown>> {
|
|
1149
1188
|
// The call uses the storages that it takes until its end: the end of a stream, for a stream
|
|
@@ -1160,7 +1199,10 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
1160
1199
|
const message = `The server cannot narrow the capability of the request: ${error instanceof Error ? error.message : String(error)}`;
|
|
1161
1200
|
return errorResponse(request.id, Object.assign(new Error(message), { code: "INTERNAL_ERROR" }), request);
|
|
1162
1201
|
}
|
|
1163
|
-
|
|
1202
|
+
// The handler runs in the context of its caller (C10): its trace always, its baggage only
|
|
1203
|
+
// when the caller is trusted
|
|
1204
|
+
const caller = callerContext(request.metadata, this.trustsCaller(request));
|
|
1205
|
+
const output = await otelContext.with(caller, () => invokeProcedure(procedure, request.payload, {
|
|
1164
1206
|
registry: this.procedureRegistry,
|
|
1165
1207
|
metadata: request.metadata,
|
|
1166
1208
|
signal,
|
|
@@ -1182,7 +1224,7 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
1182
1224
|
...(request.principal !== undefined ? { principal: request.principal } : {}),
|
|
1183
1225
|
// The bus of the remote events is the bus of the procedures (EVENTS.md)
|
|
1184
1226
|
...(this.publisher ? { bus: this.publisher.bus } : {}),
|
|
1185
|
-
});
|
|
1227
|
+
}));
|
|
1186
1228
|
if (output.kind === "stream") {
|
|
1187
1229
|
// The faces that collect a stream read this mark (roadmap R15)
|
|
1188
1230
|
const unbounded = (procedure.metadata as { stream?: { unbounded?: boolean } } | undefined)?.stream?.unbounded === true;
|