@procedures/client 0.52.2 → 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 +60 -0
- package/README.md +11 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/client/batch.d.ts.map +1 -1
- package/dist/adapters/http/client/batch.js +9 -5
- package/dist/adapters/http/client/batch.js.map +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/events/bus.d.ts +7 -0
- package/dist/events/bus.d.ts.map +1 -1
- package/dist/events/bus.js +22 -4
- package/dist/events/bus.js.map +1 -1
- package/dist/events/publisher.d.ts.map +1 -1
- package/dist/events/publisher.js +4 -3
- package/dist/events/publisher.js.map +1 -1
- package/dist/frame-encoding.d.ts +8 -5
- package/dist/frame-encoding.d.ts.map +1 -1
- package/dist/frame-encoding.js +13 -8
- package/dist/frame-encoding.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/procedures/module.d.ts.map +1 -1
- package/dist/procedures/module.js +7 -2
- package/dist/procedures/module.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 +15 -2
- 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 +30 -1
- package/dist/server/procedure-server.d.ts.map +1 -1
- package/dist/server/procedure-server.js +68 -6
- 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/client/batch.ts +8 -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/events/bus.ts +25 -4
- package/src/events/publisher.ts +6 -3
- package/src/frame-encoding.ts +15 -8
- package/src/procedures/interceptors.ts +40 -9
- package/src/procedures/module.ts +10 -2
- package/src/server/host-policy.ts +24 -2
- package/src/server/limits.ts +13 -0
- package/src/server/procedure-server.ts +79 -8
- package/src/tracing/otel.ts +122 -0
- package/src/tracing/traceparent.ts +56 -15
package/src/procedures/module.ts
CHANGED
|
@@ -159,6 +159,9 @@ export interface LazyRegistryOptions {
|
|
|
159
159
|
readonly warn?: ((message: string) => void) | undefined;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/** The most paths with no package in the index that a lazy registry warns about: 1000. */
|
|
163
|
+
const MOST_WARNED_PATHS = 1000;
|
|
164
|
+
|
|
162
165
|
/** The first line of the message of an error. */
|
|
163
166
|
function firstLine(error: unknown): string {
|
|
164
167
|
return (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
|
|
@@ -216,9 +219,14 @@ export function createLazyRegistry(options: LazyRegistryOptions): ProcedureRegis
|
|
|
216
219
|
if (named !== undefined && named.length > 0) {
|
|
217
220
|
targets = named;
|
|
218
221
|
} else {
|
|
219
|
-
|
|
222
|
+
// The set has a bound: a caller can send many names that the index does not have (audit 2026-11-08)
|
|
223
|
+
if (!warnedPaths.has(key) && warnedPaths.size < MOST_WARNED_PATHS) {
|
|
220
224
|
warnedPaths.add(key);
|
|
221
|
-
warn(
|
|
225
|
+
warn(
|
|
226
|
+
warnedPaths.size < MOST_WARNED_PATHS
|
|
227
|
+
? `the index has no package for ${key}: each package loads`
|
|
228
|
+
: `the index has no package for ${key}: each package loads. The next paths with no package get no warning.`,
|
|
229
|
+
);
|
|
222
230
|
}
|
|
223
231
|
targets = packages;
|
|
224
232
|
}
|
|
@@ -59,11 +59,22 @@ export function exposePatterns(patterns: readonly string[]): (path: ProcedurePat
|
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* This function tells if a list of excluded keys names a path. An entry can be the key of the path
|
|
64
|
+
* (`collections.e2e%2Etabs.delete`) or its segments joined with plain dots
|
|
65
|
+
* (`collections.e2e.tabs.delete`), the form before CLI-75. So an exclusion of the old form still
|
|
66
|
+
* hides its procedure (audit 2026-11-08). The plain form can hide one more path, but not one less.
|
|
67
|
+
*/
|
|
68
|
+
function isExcluded(excluded: ReadonlySet<string>, path: ProcedurePath): boolean {
|
|
69
|
+
// path-check: the raw form of an exclude entry before CLI-75, compared as text, not decoded.
|
|
70
|
+
return excluded.has(pathToKey(path)) || excluded.has(path.join("."));
|
|
71
|
+
}
|
|
72
|
+
|
|
62
73
|
/** The rule of a tool surface: the namespaces, less the excluded keys. */
|
|
63
74
|
export function surfaceRule(surface: ToolSurface): (path: ProcedurePath) => boolean {
|
|
64
75
|
const namespaces = new Set(surface.namespaces);
|
|
65
76
|
const excluded = new Set(surface.exclude ?? []);
|
|
66
|
-
return (path) => namespaces.has(path[0] ?? "") && !excluded
|
|
77
|
+
return (path) => namespaces.has(path[0] ?? "") && !isExcluded(excluded, path);
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
/** The options of `hostPolicy()`. */
|
|
@@ -85,6 +96,12 @@ export interface HostPolicyOptions {
|
|
|
85
96
|
* query) and gives a bearer token, a principal and a narrowing (`ProcedureServerOptions`).
|
|
86
97
|
*/
|
|
87
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;
|
|
88
105
|
/** The browser checks of the network transports (`allowedOrigins`, `allowedHosts`, `allowGet`). */
|
|
89
106
|
browser?: BrowserGuardOptions | undefined;
|
|
90
107
|
/**
|
|
@@ -126,6 +143,8 @@ export interface HostPolicy {
|
|
|
126
143
|
readonly token: string | undefined;
|
|
127
144
|
/** The request hook of the network requests, or undefined for none. */
|
|
128
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;
|
|
129
148
|
readonly browser: BrowserGuardOptions;
|
|
130
149
|
/** The capability of a call from a caller, or undefined for no limits. */
|
|
131
150
|
readonly capabilityOf: ((request?: ServerRequest<unknown>) => Capability | undefined) | undefined;
|
|
@@ -163,7 +182,7 @@ function exposeOf(options: HostPolicyOptions): ((path: ProcedurePath) => boolean
|
|
|
163
182
|
: surfaceRule(expose as ToolSurface);
|
|
164
183
|
const excluded = new Set(options.exclude ?? []);
|
|
165
184
|
if (excluded.size === 0) return rule;
|
|
166
|
-
return (path) => !excluded
|
|
185
|
+
return (path) => !isExcluded(excluded, path) && (rule ? rule(path) : true);
|
|
167
186
|
}
|
|
168
187
|
|
|
169
188
|
/** This function builds the policy of a host from its options. */
|
|
@@ -179,6 +198,7 @@ export function hostPolicy(options: HostPolicyOptions = {}): HostPolicy {
|
|
|
179
198
|
interceptors,
|
|
180
199
|
token: options.token,
|
|
181
200
|
...(options.principalOf ? { principalOf: options.principalOf } : {}),
|
|
201
|
+
...(options.trustBaggage ? { trustBaggage: options.trustBaggage } : {}),
|
|
182
202
|
browser: options.browser ?? {},
|
|
183
203
|
capabilityOf: capabilityOfOptions(options),
|
|
184
204
|
clearanceOrder: options.clearanceOrder,
|
|
@@ -201,6 +221,8 @@ export function procedureServerOptions(policy: HostPolicy, registry?: ProcedureR
|
|
|
201
221
|
...(policy.token !== undefined ? { token: policy.token } : {}),
|
|
202
222
|
// The request hook of the policy: the credentials of a cookie, a header or the query (CLI-2a)
|
|
203
223
|
...(policy.principalOf ? { principalOf: policy.principalOf } : {}),
|
|
224
|
+
// The callers whose baggage the handler gets (C10)
|
|
225
|
+
...(policy.trustBaggage ? { trustBaggage: policy.trustBaggage } : {}),
|
|
204
226
|
...(policy.interceptors.length > 0 ? { interceptors: policy.interceptors } : {}),
|
|
205
227
|
...(policy.capabilityOf ? { capability: policy.capabilityOf } : {}),
|
|
206
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,22 @@ 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 }>();
|
|
485
|
+
/**
|
|
486
|
+
* The admission of each request object. The late lookup of a lazy registry admits a request
|
|
487
|
+
* before it loads packages, and the call of the same request uses that result again.
|
|
488
|
+
*/
|
|
489
|
+
private readonly admitted = new WeakMap<ServerRequest<unknown>, Promise<ServerResponse<unknown> | undefined>>();
|
|
463
490
|
/** The functions that `drain()` waits on: each one runs when no call is in flight */
|
|
464
491
|
private idleWaiters: Array<() => void> = [];
|
|
465
492
|
/** The publisher of the remote events, from the option `events`, or undefined. */
|
|
@@ -492,6 +519,7 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
492
519
|
this.token = options.token;
|
|
493
520
|
this.principalOf = options.principalOf;
|
|
494
521
|
this.authenticate = options.authenticate;
|
|
522
|
+
this.trustBaggage = options.trustBaggage;
|
|
495
523
|
const ambient = options.ambientCredentials ?? (this.principalOf ? "always" : this.authenticate ? "cookies" : undefined);
|
|
496
524
|
this.ambientCredentials = ambient === false ? undefined : ambient;
|
|
497
525
|
this.debugReport =
|
|
@@ -847,7 +875,20 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
847
875
|
*/
|
|
848
876
|
protected override findLateHandler(method: Method, request: ServerRequest<unknown>): Promise<ServerHandler | null> | null {
|
|
849
877
|
if (!this.servesRegistry || !this.procedureRegistry.lazy) return null;
|
|
850
|
-
|
|
878
|
+
const path = methodToPath(method);
|
|
879
|
+
// A path outside the expose rule of the face loads nothing, as findHandler() gives it no handler
|
|
880
|
+
if (request.expose !== undefined) {
|
|
881
|
+
const rule: unknown = request.expose;
|
|
882
|
+
if (typeof rule !== "function" || rule(path) !== true) return null;
|
|
883
|
+
}
|
|
884
|
+
return (async (): Promise<ServerHandler | null> => {
|
|
885
|
+
// Only an admitted request loads packages (audit 2026-11-08). Before, a caller with no
|
|
886
|
+
// credentials loaded packages, and got the load errors of the packages.
|
|
887
|
+
const refusal = await this.admit(request);
|
|
888
|
+
if (refusal) return () => Promise.resolve(refusal);
|
|
889
|
+
await this.procedureRegistry.resolve(path);
|
|
890
|
+
return this.findHandler(method, request);
|
|
891
|
+
})();
|
|
851
892
|
}
|
|
852
893
|
|
|
853
894
|
/**
|
|
@@ -896,9 +937,20 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
896
937
|
* request hook refuses it, and the hook does not run (CLI-1). The authenticate function gives
|
|
897
938
|
* the principal. The credentials of an admission of this server (`admission()`) skip the
|
|
898
939
|
* checks: the request gets the principal and the narrowing of that admission (CLI-2b). The
|
|
899
|
-
* function gives an UNAUTHORIZED response, or undefined when the request passes.
|
|
900
|
-
|
|
901
|
-
|
|
940
|
+
* function gives an UNAUTHORIZED response, or undefined when the request passes. The checks
|
|
941
|
+
* run one time for each request object.
|
|
942
|
+
*/
|
|
943
|
+
private admit(request: ServerRequest<unknown>): Promise<ServerResponse<unknown> | undefined> {
|
|
944
|
+
let result = this.admitted.get(request);
|
|
945
|
+
if (result === undefined) {
|
|
946
|
+
result = this.checkRequest(request);
|
|
947
|
+
this.admitted.set(request, result);
|
|
948
|
+
}
|
|
949
|
+
return result;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/** The checks of `admit()` for one request object. */
|
|
953
|
+
private async checkRequest(request: ServerRequest<unknown>): Promise<ServerResponse<unknown> | undefined> {
|
|
902
954
|
const unauthorized = (message: string): ServerResponse<unknown> => ({
|
|
903
955
|
id: request.id,
|
|
904
956
|
status: { type: "error", code: "UNAUTHORIZED", message, retryable: false },
|
|
@@ -908,6 +960,7 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
908
960
|
// again. Only this server makes such credentials, so a copy or a forgery has no entry.
|
|
909
961
|
const admitted = request.credentials !== undefined ? this.admissions.get(request.credentials) : undefined;
|
|
910
962
|
if (admitted) {
|
|
963
|
+
if (admitted.trusted) this.trustedCallers.add(request);
|
|
911
964
|
if (admitted.principal) request.principal = admitted.principal;
|
|
912
965
|
if (admitted.narrowing) {
|
|
913
966
|
const order = this.capabilityOptions.clearanceOrder;
|
|
@@ -924,6 +977,8 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
924
977
|
// A local face (this process, MCP over stdio, a message port) sets the local marker. The
|
|
925
978
|
// marker key is a symbol, so a request from the network cannot carry it (CLI-1).
|
|
926
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);
|
|
927
982
|
let identity: RequestIdentity | undefined;
|
|
928
983
|
if (this.principalOf && credentials !== undefined) {
|
|
929
984
|
try {
|
|
@@ -937,6 +992,8 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
937
992
|
const bearer = identity?.bearer ?? credentials.bearer;
|
|
938
993
|
if (!tokenMatches(this.token, bearer)) return unauthorized("Unauthorized: this server requires its token");
|
|
939
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);
|
|
940
997
|
}
|
|
941
998
|
if (identity?.principal) request.principal = identity.principal;
|
|
942
999
|
if (identity?.narrowing) {
|
|
@@ -1034,7 +1091,7 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
1034
1091
|
return { ok: false, code: "UNAUTHORIZED", message: refusal.status.type === "error" ? refusal.status.message : "Unauthorized" };
|
|
1035
1092
|
}
|
|
1036
1093
|
const admitted: Credentials = Object.freeze({ ...credentials });
|
|
1037
|
-
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) });
|
|
1038
1095
|
return { ok: true, credentials: admitted, ...(request.principal ? { principal: request.principal } : {}) };
|
|
1039
1096
|
}
|
|
1040
1097
|
|
|
@@ -1115,6 +1172,17 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
1115
1172
|
};
|
|
1116
1173
|
}
|
|
1117
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
|
+
|
|
1118
1186
|
/** Run a procedure for a request with a signal. */
|
|
1119
1187
|
private async run(procedure: AnyProcedure, request: ServerRequest<unknown>, signal: AbortSignal | undefined): Promise<ServerResponse<unknown>> {
|
|
1120
1188
|
// The call uses the storages that it takes until its end: the end of a stream, for a stream
|
|
@@ -1131,7 +1199,10 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
1131
1199
|
const message = `The server cannot narrow the capability of the request: ${error instanceof Error ? error.message : String(error)}`;
|
|
1132
1200
|
return errorResponse(request.id, Object.assign(new Error(message), { code: "INTERNAL_ERROR" }), request);
|
|
1133
1201
|
}
|
|
1134
|
-
|
|
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, {
|
|
1135
1206
|
registry: this.procedureRegistry,
|
|
1136
1207
|
metadata: request.metadata,
|
|
1137
1208
|
signal,
|
|
@@ -1153,7 +1224,7 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
|
|
|
1153
1224
|
...(request.principal !== undefined ? { principal: request.principal } : {}),
|
|
1154
1225
|
// The bus of the remote events is the bus of the procedures (EVENTS.md)
|
|
1155
1226
|
...(this.publisher ? { bus: this.publisher.bus } : {}),
|
|
1156
|
-
});
|
|
1227
|
+
}));
|
|
1157
1228
|
if (output.kind === "stream") {
|
|
1158
1229
|
// The faces that collect a stream read this mark (roadmap R15)
|
|
1159
1230
|
const unbounded = (procedure.metadata as { stream?: { unbounded?: boolean } } | undefined)?.stream?.unbounded === true;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The context of OpenTelemetry for the calls of a client and the invocations of a host (roadmap
|
|
3
|
+
* item C8 of otel-ts, decisions D5 and D6). This module uses `@opentelemetry/api` only. The API
|
|
4
|
+
* does nothing until a program registers an SDK, for example with `startNodeTelemetry()` of
|
|
5
|
+
* `@mark1russell7/otel-ts/node`.
|
|
6
|
+
*
|
|
7
|
+
* - With an SDK, each call gets a span of the API, and the registered propagator writes the
|
|
8
|
+
* context of the call into its metadata: `traceparent`, `tracestate` and `baggage`.
|
|
9
|
+
* - With no SDK, `startApiSpan` gives undefined, and the W3C code of `traceparent.ts` operates as
|
|
10
|
+
* before (D6).
|
|
11
|
+
*/
|
|
12
|
+
import { context, createContextKey, isSpanContextValid, propagation, ROOT_CONTEXT, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
13
|
+
import type { Context, Span, SpanKind, TextMapSetter } from "@opentelemetry/api";
|
|
14
|
+
|
|
15
|
+
/** The name of the tracer of the spans of this package */
|
|
16
|
+
export const TRACER_NAME = "@procedures/client";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The key of the mark `internal call`. It is the same key as the key of `withInternalCall` of
|
|
20
|
+
* `@mark1russell7/otel-ts/context`, because `createContextKey` gives `Symbol.for(description)`.
|
|
21
|
+
* The propagator of otel-ts sends the baggage only on a call with this mark. A test pins the
|
|
22
|
+
* description against otel-ts.
|
|
23
|
+
*/
|
|
24
|
+
export const INTERNAL_CALL_KEY: symbol = createContextKey("@mark1russell7/otel-ts/context internal call");
|
|
25
|
+
|
|
26
|
+
/** This function gives `from` with the mark `internal call`. */
|
|
27
|
+
export function asInternalCall(from: Context): Context {
|
|
28
|
+
return from.setValue(INTERNAL_CALL_KEY, true);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* This function starts a span of the API, or gives undefined when no SDK gives the span ids of
|
|
33
|
+
* its own. With no SDK, the API gives a span with no ids, or with the ids of its parent.
|
|
34
|
+
*/
|
|
35
|
+
export function startApiSpan(name: string, kind: SpanKind, parent: Context): Span | undefined {
|
|
36
|
+
const parentSpan = trace.getSpanContext(parent);
|
|
37
|
+
const span = trace.getTracer(TRACER_NAME).startSpan(name, { kind }, parent);
|
|
38
|
+
const own = span.spanContext();
|
|
39
|
+
if (isSpanContextValid(own) && own.spanId !== parentSpan?.spanId) return span;
|
|
40
|
+
span.end();
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** This function ends `span`, with the status of `error` when the call failed. */
|
|
45
|
+
export function endApiSpan(span: Span, error?: unknown): void {
|
|
46
|
+
if (error !== undefined) {
|
|
47
|
+
span.recordException(error instanceof Error ? error : String(error));
|
|
48
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.message : String(error) });
|
|
49
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
50
|
+
if (typeof code === "string") span.setAttribute("procedure.error.code", code);
|
|
51
|
+
}
|
|
52
|
+
span.end();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const metadataSetter: TextMapSetter<Record<string, unknown>> = {
|
|
56
|
+
set: (carrier, key, value) => {
|
|
57
|
+
carrier[key] = value;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** This function writes the context `from` into `metadata`, with the registered propagator. */
|
|
62
|
+
export function injectContext(from: Context, metadata: Record<string, unknown>): void {
|
|
63
|
+
propagation.inject(from, metadata, metadataSetter);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* This function gives the parent context of an invocation of a host: the active context, with the
|
|
68
|
+
* remote span of the `traceparent` and `tracestate` of `metadata` when they are valid. The baggage
|
|
69
|
+
* of the metadata is not read: the host reads it only from a trusted caller (`@procedures/peer`).
|
|
70
|
+
*/
|
|
71
|
+
export function remoteParent(metadata: Record<string, unknown>, from: Context = context.active()): Context {
|
|
72
|
+
const traceparent = metadata["traceparent"];
|
|
73
|
+
if (typeof traceparent !== "string") return from;
|
|
74
|
+
const carrier: Record<string, string> = { traceparent };
|
|
75
|
+
if (typeof metadata["tracestate"] === "string") carrier["tracestate"] = metadata["tracestate"];
|
|
76
|
+
const extracted = propagation.extract(from, carrier);
|
|
77
|
+
return isSpanContextValid(trace.getSpanContext(extracted) ?? { traceId: "", spanId: "", traceFlags: 0 }) ? extracted : from;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The trace context that the caller of a call gave in its metadata: `traceparent` and `tracestate`, strings only */
|
|
81
|
+
export type CallerTrace = { readonly traceparent?: string; readonly tracestate?: string };
|
|
82
|
+
|
|
83
|
+
/** This function gives the `traceparent` and `tracestate` of `metadata`, before the client writes its own. */
|
|
84
|
+
export function callerTraceOf(metadata: Readonly<Record<string, unknown>>): CallerTrace {
|
|
85
|
+
const { traceparent, tracestate } = metadata;
|
|
86
|
+
return {
|
|
87
|
+
...(typeof traceparent === "string" ? { traceparent } : {}),
|
|
88
|
+
...(typeof tracestate === "string" ? { tracestate } : {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* This function gives the parent context of a try of a call of a client (roadmap item C19 of
|
|
94
|
+
* otel-ts): the active context when it has a valid span, else the remote span of `caller`, the
|
|
95
|
+
* trace context that the caller gave in the metadata of the call. For example, `mark` gives the
|
|
96
|
+
* environment variables `TRACEPARENT` and `TRACESTATE` there. Without this, the span of the call
|
|
97
|
+
* started a new trace, and the propagator wrote that trace over the trace context of the caller.
|
|
98
|
+
*/
|
|
99
|
+
export function callParent(caller: CallerTrace, from: Context = context.active()): Context {
|
|
100
|
+
const active = trace.getSpanContext(from);
|
|
101
|
+
return active !== undefined && isSpanContextValid(active) ? from : remoteParent(caller, from);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* This function gives the context of the caller of an invocation of a host (roadmap item C10 of
|
|
106
|
+
* otel-ts): the handler runs in it, so its logs, metrics, spans and nested calls carry the ids of
|
|
107
|
+
* the caller.
|
|
108
|
+
*
|
|
109
|
+
* - The remote span of the `traceparent` and `tracestate` of `metadata` is always the parent.
|
|
110
|
+
* - The `baggage` of `metadata` replaces the baggage of `from` only when the caller is trusted.
|
|
111
|
+
* - When the caller is not trusted, the context has no baggage: an end user cannot set the
|
|
112
|
+
* baggage of the services. The baggage of `from` can come from the headers of the same request,
|
|
113
|
+
* for example from the HTTP instrumentation of an SDK, so the function removes it too.
|
|
114
|
+
*/
|
|
115
|
+
export function callerContext(metadata: Record<string, unknown>, trusted: boolean, from: Context = context.active()): Context {
|
|
116
|
+
const parent = remoteParent(metadata, from);
|
|
117
|
+
if (!trusted) return propagation.getBaggage(parent) === undefined ? parent : propagation.deleteBaggage(parent);
|
|
118
|
+
const baggage = metadata["baggage"];
|
|
119
|
+
if (typeof baggage !== "string") return parent;
|
|
120
|
+
const extracted = propagation.getBaggage(propagation.extract(ROOT_CONTEXT, { baggage }));
|
|
121
|
+
return extracted === undefined ? parent : propagation.setBaggage(parent, extracted);
|
|
122
|
+
}
|
|
@@ -4,16 +4,32 @@
|
|
|
4
4
|
* the metadata of a call (and HTTP as the `traceparent` header). The tracing interceptor of a
|
|
5
5
|
* server continues that trace, and gives it to the nested calls, so one trace goes from the
|
|
6
6
|
* client to the last handler. Before, the client and the server made two traces.
|
|
7
|
+
*
|
|
8
|
+
* The checks of the ids are `isValidTraceId` and `isValidSpanId` of `@opentelemetry/api`
|
|
9
|
+
* (decision D7 of otel-ts). They refuse an id of only zeros.
|
|
7
10
|
*/
|
|
11
|
+
import { isValidSpanId, isValidTraceId } from "@opentelemetry/api";
|
|
8
12
|
|
|
9
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* The metadata key of the W3C trace context of a call.
|
|
15
|
+
*
|
|
16
|
+
* @deprecated With an SDK of OpenTelemetry, the registered propagator writes and reads the trace
|
|
17
|
+
* context (roadmap C8 of otel-ts). A later MINOR removes this export, when each app registers an SDK (D6).
|
|
18
|
+
*/
|
|
10
19
|
export const TRACEPARENT_METADATA_KEY = "traceparent";
|
|
11
20
|
|
|
21
|
+
/** The form of version 00. The ids in it have only lowercase digits, as W3C Trace Context requires. */
|
|
12
22
|
const TRACEPARENT = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
13
|
-
const ZERO_TRACE = "0".repeat(32);
|
|
14
|
-
const ZERO_SPAN = "0".repeat(16);
|
|
15
23
|
|
|
16
|
-
/**
|
|
24
|
+
/** An uppercase hex digit. The checks of the API accept it, but W3C Trace Context does not. */
|
|
25
|
+
const UPPERCASE_HEX = /[A-F]/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A trace context: the trace, the span of the caller, and the sampled flag.
|
|
29
|
+
*
|
|
30
|
+
* @deprecated With an SDK of OpenTelemetry, the registered propagator writes and reads the trace
|
|
31
|
+
* context (roadmap C8 of otel-ts). A later MINOR removes this export, when each app registers an SDK (D6).
|
|
32
|
+
*/
|
|
17
33
|
export interface TraceContext {
|
|
18
34
|
readonly traceId: string;
|
|
19
35
|
readonly spanId: string;
|
|
@@ -51,34 +67,59 @@ function randomHex(bytes: number): string {
|
|
|
51
67
|
return hex;
|
|
52
68
|
}
|
|
53
69
|
|
|
54
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* This function makes a new trace id: 32 random hex digits.
|
|
72
|
+
*
|
|
73
|
+
* @deprecated With an SDK of OpenTelemetry, the registered propagator writes and reads the trace
|
|
74
|
+
* context (roadmap C8 of otel-ts). A later MINOR removes this export, when each app registers an SDK (D6).
|
|
75
|
+
*/
|
|
55
76
|
export function newTraceId(): string {
|
|
56
77
|
const id = randomHex(16);
|
|
57
|
-
return id
|
|
78
|
+
return isValidTraceId(id) ? id : newTraceId();
|
|
58
79
|
}
|
|
59
80
|
|
|
60
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* This function makes a new span id: 16 random hex digits.
|
|
83
|
+
*
|
|
84
|
+
* @deprecated With an SDK of OpenTelemetry, the registered propagator writes and reads the trace
|
|
85
|
+
* context (roadmap C8 of otel-ts). A later MINOR removes this export, when each app registers an SDK (D6).
|
|
86
|
+
*/
|
|
61
87
|
export function newSpanId(): string {
|
|
62
88
|
const id = randomHex(8);
|
|
63
|
-
return id
|
|
89
|
+
return isValidSpanId(id) ? id : newSpanId();
|
|
64
90
|
}
|
|
65
91
|
|
|
66
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* This function gives true when the ids have the W3C form.
|
|
94
|
+
*
|
|
95
|
+
* @deprecated With an SDK of OpenTelemetry, the registered propagator writes and reads the trace
|
|
96
|
+
* context (roadmap C8 of otel-ts). A later MINOR removes this export, when each app registers an SDK (D6).
|
|
97
|
+
*/
|
|
67
98
|
export function isW3cIds(traceId: string, spanId: string): boolean {
|
|
68
|
-
return
|
|
99
|
+
return isValidTraceId(traceId) && isValidSpanId(spanId) && !UPPERCASE_HEX.test(traceId) && !UPPERCASE_HEX.test(spanId);
|
|
69
100
|
}
|
|
70
101
|
|
|
71
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* This function gives the `traceparent` of a trace and a span.
|
|
104
|
+
*
|
|
105
|
+
* @deprecated With an SDK of OpenTelemetry, the registered propagator writes and reads the trace
|
|
106
|
+
* context (roadmap C8 of otel-ts). A later MINOR removes this export, when each app registers an SDK (D6).
|
|
107
|
+
*/
|
|
72
108
|
export function formatTraceparent(traceId: string, spanId: string, sampled = true): string {
|
|
73
109
|
return `00-${traceId}-${spanId}-${sampled ? "01" : "00"}`;
|
|
74
110
|
}
|
|
75
111
|
|
|
76
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* This function gives the trace context of a `traceparent` value, or undefined when it is not valid.
|
|
114
|
+
*
|
|
115
|
+
* @deprecated With an SDK of OpenTelemetry, the registered propagator writes and reads the trace
|
|
116
|
+
* context (roadmap C8 of otel-ts). A later MINOR removes this export, when each app registers an SDK (D6).
|
|
117
|
+
*/
|
|
77
118
|
export function parseTraceparent(value: unknown): TraceContext | undefined {
|
|
78
119
|
if (typeof value !== "string") return undefined;
|
|
79
120
|
const match = TRACEPARENT.exec(value.trim().toLowerCase());
|
|
80
121
|
if (!match) return undefined;
|
|
81
|
-
const [, traceId, spanId, flags] = match;
|
|
82
|
-
if (traceId
|
|
83
|
-
return { traceId
|
|
122
|
+
const [, traceId = "", spanId = "", flags = ""] = match;
|
|
123
|
+
if (!isValidTraceId(traceId) || !isValidSpanId(spanId)) return undefined;
|
|
124
|
+
return { traceId, spanId, sampled: (parseInt(flags, 16) & 1) === 1 };
|
|
84
125
|
}
|