@procedures/client 0.9.0 → 0.9.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.
Files changed (115) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/adapters/http/server/addressing.d.ts +78 -0
  4. package/dist/adapters/http/server/addressing.d.ts.map +1 -0
  5. package/dist/adapters/http/server/addressing.js +62 -0
  6. package/dist/adapters/http/server/addressing.js.map +1 -0
  7. package/dist/adapters/http/server/cors.d.ts.map +1 -1
  8. package/dist/adapters/http/server/cors.js +3 -0
  9. package/dist/adapters/http/server/cors.js.map +1 -1
  10. package/dist/adapters/http/server/handler.d.ts +9 -1
  11. package/dist/adapters/http/server/handler.d.ts.map +1 -1
  12. package/dist/adapters/http/server/handler.js +49 -23
  13. package/dist/adapters/http/server/handler.js.map +1 -1
  14. package/dist/adapters/http/server/strategies.d.ts +1 -0
  15. package/dist/adapters/http/server/strategies.d.ts.map +1 -1
  16. package/dist/adapters/http/server/strategies.js +6 -0
  17. package/dist/adapters/http/server/strategies.js.map +1 -1
  18. package/dist/adapters/http/server/transport.d.ts.map +1 -1
  19. package/dist/adapters/http/server/transport.js +7 -2
  20. package/dist/adapters/http/server/transport.js.map +1 -1
  21. package/dist/adapters/http/server/types.d.ts +7 -0
  22. package/dist/adapters/http/server/types.d.ts.map +1 -1
  23. package/dist/adapters/http/shared/strategies.d.ts +7 -0
  24. package/dist/adapters/http/shared/strategies.d.ts.map +1 -1
  25. package/dist/adapters/http/shared/strategies.js +26 -7
  26. package/dist/adapters/http/shared/strategies.js.map +1 -1
  27. package/dist/adapters/local/client/types.d.ts.map +1 -1
  28. package/dist/adapters/local/client/types.js +3 -1
  29. package/dist/adapters/local/client/types.js.map +1 -1
  30. package/dist/browser.d.ts +5 -2
  31. package/dist/browser.d.ts.map +1 -1
  32. package/dist/browser.js +4 -2
  33. package/dist/browser.js.map +1 -1
  34. package/dist/client/client.d.ts +6 -0
  35. package/dist/client/client.d.ts.map +1 -1
  36. package/dist/client/client.js +3 -1
  37. package/dist/client/client.js.map +1 -1
  38. package/dist/client/errors/factory.d.ts.map +1 -1
  39. package/dist/client/errors/factory.js +2 -3
  40. package/dist/client/errors/factory.js.map +1 -1
  41. package/dist/client/middleware/cache.d.ts.map +1 -1
  42. package/dist/client/middleware/cache.js +2 -1
  43. package/dist/client/middleware/cache.js.map +1 -1
  44. package/dist/events/publisher.d.ts +14 -1
  45. package/dist/events/publisher.d.ts.map +1 -1
  46. package/dist/events/publisher.js +20 -6
  47. package/dist/events/publisher.js.map +1 -1
  48. package/dist/node.d.ts +6 -0
  49. package/dist/node.d.ts.map +1 -1
  50. package/dist/node.js +6 -0
  51. package/dist/node.js.map +1 -1
  52. package/dist/procedures/collection/index.d.ts +1 -1
  53. package/dist/procedures/collection/index.d.ts.map +1 -1
  54. package/dist/procedures/collection/index.js +1 -1
  55. package/dist/procedures/collection/index.js.map +1 -1
  56. package/dist/procedures/collection/partition.d.ts +21 -2
  57. package/dist/procedures/collection/partition.d.ts.map +1 -1
  58. package/dist/procedures/collection/partition.js +19 -1
  59. package/dist/procedures/collection/partition.js.map +1 -1
  60. package/dist/procedures/index.d.ts +3 -1
  61. package/dist/procedures/index.d.ts.map +1 -1
  62. package/dist/procedures/index.js +3 -1
  63. package/dist/procedures/index.js.map +1 -1
  64. package/dist/procedures/invoke.d.ts +13 -0
  65. package/dist/procedures/invoke.d.ts.map +1 -1
  66. package/dist/procedures/invoke.js +29 -0
  67. package/dist/procedures/invoke.js.map +1 -1
  68. package/dist/procedures/narrowing.d.ts +93 -0
  69. package/dist/procedures/narrowing.d.ts.map +1 -0
  70. package/dist/procedures/narrowing.js +328 -0
  71. package/dist/procedures/narrowing.js.map +1 -0
  72. package/dist/procedures/types.d.ts +6 -0
  73. package/dist/procedures/types.d.ts.map +1 -1
  74. package/dist/procedures/types.js.map +1 -1
  75. package/dist/server/index.d.ts +2 -2
  76. package/dist/server/index.d.ts.map +1 -1
  77. package/dist/server/index.js +1 -1
  78. package/dist/server/index.js.map +1 -1
  79. package/dist/server/method.d.ts +8 -2
  80. package/dist/server/method.d.ts.map +1 -1
  81. package/dist/server/method.js +16 -5
  82. package/dist/server/method.js.map +1 -1
  83. package/dist/server/procedure-server.d.ts +18 -1
  84. package/dist/server/procedure-server.d.ts.map +1 -1
  85. package/dist/server/procedure-server.js +31 -2
  86. package/dist/server/procedure-server.js.map +1 -1
  87. package/dist/server/types.d.ts +16 -0
  88. package/dist/server/types.d.ts.map +1 -1
  89. package/dist/server/types.js +2 -1
  90. package/dist/server/types.js.map +1 -1
  91. package/package.json +1 -1
  92. package/src/adapters/http/server/addressing.ts +118 -0
  93. package/src/adapters/http/server/cors.ts +3 -0
  94. package/src/adapters/http/server/handler.ts +57 -23
  95. package/src/adapters/http/server/strategies.ts +7 -0
  96. package/src/adapters/http/server/transport.ts +8 -3
  97. package/src/adapters/http/server/types.ts +8 -0
  98. package/src/adapters/http/shared/strategies.ts +25 -7
  99. package/src/adapters/local/client/types.ts +3 -1
  100. package/src/browser.ts +6 -1
  101. package/src/client/client.ts +8 -1
  102. package/src/client/errors/factory.ts +2 -3
  103. package/src/client/middleware/cache.ts +2 -1
  104. package/src/events/publisher.ts +26 -7
  105. package/src/node.ts +7 -0
  106. package/src/procedures/collection/index.ts +1 -1
  107. package/src/procedures/collection/partition.ts +24 -2
  108. package/src/procedures/index.ts +4 -0
  109. package/src/procedures/invoke.ts +38 -0
  110. package/src/procedures/narrowing.ts +357 -0
  111. package/src/procedures/types.ts +7 -0
  112. package/src/server/index.ts +2 -1
  113. package/src/server/method.ts +17 -5
  114. package/src/server/procedure-server.ts +42 -2
  115. package/src/server/types.ts +19 -1
@@ -27,8 +27,9 @@ import { PROTOCOL_HEADER, PROTOCOL_VERSION, httpStatusOf, protocolVersionOf } fr
27
27
  import { realClock, type Cancel, type Clock } from "../../../clock.js";
28
28
  import { ms, type Duration } from "../../../duration.js";
29
29
  import { NDJSON_MEDIA_TYPE as NDJSON, defaultNdjsonError as errorOf, keepAliveMsOf, writeNdjsonStream } from "./ndjson.js";
30
- import { createPatternServerUrlStrategy } from "./strategies.js";
31
30
  import { applyCors, corsOrigins, corsPolicy, type CorsOptions, type CorsPolicy } from "./cors.js";
31
+ import { urlAddressing, type Addressing } from "./addressing.js";
32
+ import { NARROWING_METADATA_KEY, addNarrowing } from "../../../procedures/narrowing.js";
32
33
 
33
34
  /** The parts of a request that a URL strategy of `procedureHandler` reads. */
34
35
  export interface UrlRequest {
@@ -58,9 +59,16 @@ export interface ProcedureHandlerOptions extends BrowserGuardOptions {
58
59
  readonly basePath?: string | undefined;
59
60
  /**
60
61
  * The method of a request. The default reads the URL format of `HttpTransport`:
61
- * `{basePath}/{service}/{operation}`.
62
+ * `{basePath}/{service}/{operation}`. The option `addressing` replaces it.
62
63
  */
63
64
  readonly urlStrategy?: NodeUrlStrategy | undefined;
65
+ /**
66
+ * The addressing of a request (CLI-31): a hook of the request and its body that gives the
67
+ * method and the payload of the call. `bodyPathAddressing()` reads the path from the body of a
68
+ * single endpoint.
69
+ * @default urlAddressing({ basePath, urlStrategy })
70
+ */
71
+ readonly addressing?: Addressing | undefined;
64
72
  /**
65
73
  * The CORS headers of the requests on the path. `true` uses the defaults: each origin, without
66
74
  * credentials. The permitted origins of the browser checks follow the CORS origins.
@@ -207,6 +215,19 @@ const GUARD_REASONS: Record<string, string> = {
207
215
  UNSUPPORTED_MEDIA_TYPE: "media-type-not-allowed",
208
216
  };
209
217
 
218
+ /** This function sets a key as an own property, also the key `__proto__` (the `put` of the query language). */
219
+ function put(target: Record<string, unknown>, key: string, value: unknown): void {
220
+ Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
221
+ }
222
+
223
+ /**
224
+ * This function sets a key at the root of the metadata. Each source of the narrowing key adds to
225
+ * the key, so no source replaces another (CLI-30).
226
+ */
227
+ function setMetadata(metadata: Metadata, key: string, value: unknown): void {
228
+ metadata[key] = key === NARROWING_METADATA_KEY ? addNarrowing(metadata[key], value) : value;
229
+ }
230
+
210
231
  /** This function writes a JSON response without a framework. */
211
232
  function writeJson(res: NodeResponse, status: number, body: unknown): void {
212
233
  const text = JSON.stringify(body);
@@ -286,14 +307,14 @@ export function createProcedureHandler(server: RequestHandler, options: Procedur
286
307
  assertNamedOrigins(compat.face?.name ?? "procedureHandler", ambientMode, guard.allowedOrigins, compat.face?.originOption ?? "cors.origin");
287
308
  const secretHeaders = headerNames(options.secretHeaders);
288
309
  const secretQuery = new Set(options.secretQuery ?? (legacy ? [] : ["token"]));
289
- const strategy = options.urlStrategy ?? createPatternServerUrlStrategy(basePath);
290
- const methodOf =
291
- compat.methodOf ??
292
- ((req: IncomingMessage): Method | null => {
293
- const { path, query } = urlParts(req.url);
294
- const view: UrlRequest = { method: (req.method ?? "GET").toUpperCase(), path, url: req.url ?? "/", headers: req.headers, query: queryRecord(query) };
295
- return strategy(view, req);
296
- });
310
+ // The addressing of a request (CLI-31): the option, or the URL strategy of the shim, or the URL
311
+ // format of HttpTransport
312
+ const compatMethodOf = compat.methodOf;
313
+ const addressing: Addressing =
314
+ options.addressing ??
315
+ urlAddressing(compatMethodOf ? { urlStrategy: (_view, req) => compatMethodOf(req) } : { basePath, urlStrategy: options.urlStrategy });
316
+ // A single endpoint (the path in the body) is the base path itself
317
+ const bareBase = prefix.slice(0, -1);
297
318
  const sendJson = compat.sendJson ?? writeJson;
298
319
 
299
320
  /** The controllers of the requests in progress: close() aborts them (deep dive TRN-15) */
@@ -309,7 +330,11 @@ export function createProcedureHandler(server: RequestHandler, options: Procedur
309
330
  for (const waiter of waiters) waiter();
310
331
  };
311
332
 
312
- const matches = (req: IncomingMessage): boolean => compat.matchAll === true || pathOf(req).startsWith(prefix);
333
+ const matches = (req: IncomingMessage): boolean => {
334
+ if (compat.matchAll === true) return true;
335
+ const path = pathOf(req);
336
+ return path.startsWith(prefix) || (addressing.takesBasePath === true && path === bareBase);
337
+ };
313
338
 
314
339
  /** The error body of wire v1: the message, the code, the retryable flag and the more fields. */
315
340
  const sendError = (res: NodeResponse, status: number, message: string, code: string, retryable = false, more: Record<string, unknown> = {}): void => {
@@ -349,16 +374,23 @@ export function createProcedureHandler(server: RequestHandler, options: Procedur
349
374
  }
350
375
  };
351
376
 
352
- /** This function converts an HTTP request to a `ServerRequest`, or null when the URL names no method. */
377
+ /**
378
+ * This function converts an HTTP request to a `ServerRequest`, or gives null when the addressing
379
+ * finds no call in the request.
380
+ */
353
381
  const toServerRequest = (req: IncomingMessage, body: unknown): ServerRequest | null => {
354
- const method = methodOf(req);
355
- if (!method) return null;
382
+ const { path, query: urlQuery } = urlParts(req.url);
383
+ const view: UrlRequest = { method: (req.method ?? "GET").toUpperCase(), path, url: req.url ?? "/", headers: req.headers, query: queryRecord(urlQuery) };
384
+ const address = addressing(view, body, req);
385
+ if (!address) return null;
386
+ const method = address.method;
356
387
 
357
388
  const id = (req.headers["x-request-id"] as string | undefined) ?? `req-${Date.now()}-${Math.random()}`;
358
389
 
359
390
  // The query without its secret parameters: they go into the credentials (CLI-2a)
391
+ // An own property for each key, also `__proto__`: a key of the request changes no prototype
360
392
  const query: Record<string, unknown> = {};
361
- for (const [key, value] of Object.entries(queryOf(req))) if (!secretQuery.has(key)) query[key] = value;
393
+ for (const [key, value] of Object.entries(queryOf(req))) if (!secretQuery.has(key)) put(query, key, value);
362
394
  const params = paramsOf(req);
363
395
 
364
396
  // The metadata of the headers and of the query. The query parameters cannot replace headers,
@@ -370,17 +402,21 @@ export function createProcedureHandler(server: RequestHandler, options: Procedur
370
402
  params,
371
403
  };
372
404
  for (const [key, value] of Object.entries(query)) {
373
- if (!RESERVED_METADATA.has(key) && !key.startsWith("__")) metadata[key] = value;
405
+ if (!RESERVED_METADATA.has(key) && !key.startsWith("__")) setMetadata(metadata, key, value);
374
406
  }
375
407
 
376
408
  // The custom headers at the root of the metadata, as the client's metadataToHeaders() sends
377
409
  // them. A secret header (Cookie too) does not go there.
378
410
  for (const [key, value] of Object.entries(req.headers)) {
379
411
  const lower = key.toLowerCase();
380
- if (STANDARD_HEADERS.has(lower) || isSecretHeader(lower, secretHeaders) || typeof value !== "string") continue;
412
+ if (STANDARD_HEADERS.has(lower) || isSecretHeader(lower, secretHeaders) || typeof value !== "string" || lower === NARROWING_METADATA_KEY) continue;
381
413
  const normalizedKey = HEADER_MAPPINGS[lower] ?? key;
382
414
  if (!RESERVED_METADATA.has(normalizedKey) && !normalizedKey.startsWith("__")) metadata[normalizedKey] = value;
383
415
  }
416
+ // Each value of the narrowing header is one source, also each line of a repeated header. No
417
+ // source replaces another: a gateway's header and the client's key of X-Metadata both apply (CLI-30)
418
+ const narrowingHeader = req.headersDistinct?.[NARROWING_METADATA_KEY] ?? req.headers[NARROWING_METADATA_KEY];
419
+ for (const value of [narrowingHeader].flat()) if (typeof value === "string") setMetadata(metadata, NARROWING_METADATA_KEY, value);
384
420
 
385
421
  // The custom metadata of the HTTP client: any JSON value, any key (deep dive TRN-11)
386
422
  const encoded = req.headers["x-metadata"];
@@ -391,15 +427,13 @@ export function createProcedureHandler(server: RequestHandler, options: Procedur
391
427
  const custom = decodeMetadataHeader(encoded);
392
428
  if (!custom) throw new BodyError("The X-Metadata header is not URI-encoded JSON of an object", 400);
393
429
  for (const [key, value] of Object.entries(custom)) {
394
- if (!RESERVED_METADATA.has(key) && key !== "auth" && !key.startsWith("__")) metadata[key] = value;
430
+ if (!RESERVED_METADATA.has(key) && key !== "auth" && !key.startsWith("__")) setMetadata(metadata, key, value);
395
431
  }
396
432
  }
397
433
 
398
- // The payload: the body, or the id of a RESTful GET. Only an absent payload becomes {}: 0,
399
- // false, "" and null stay (deep dive TRN-10)
400
- let payload: unknown = body;
401
- if (req.method === "GET" && params["id"]) payload = { id: params["id"] };
402
- if (payload === undefined) payload = {};
434
+ // The payload of the addressing (the body, or the id of a RESTful GET). Only an absent payload
435
+ // becomes {}: 0, false, "" and null stay (deep dive TRN-10)
436
+ const payload: unknown = address.payload === undefined ? {} : address.payload;
403
437
 
404
438
  return {
405
439
  id,
@@ -71,10 +71,17 @@ export function defaultServerUrlStrategy(req: RoutedRequest): Method | null {
71
71
  * Maps:
72
72
  * - POST /rpc/users/get → { service: "users", operation: "get" }
73
73
  * - POST /rpc/users/list → { service: "users", operation: "list" }
74
+ * - POST /rpc/morning → { service: "", operation: "morning" } (a path of one segment, CLI-28)
74
75
  */
75
76
  export function rpcServerUrlStrategy(req: RoutedRequest): Method | null {
76
77
  const path = req.path;
77
78
 
79
+ // A path of one segment: /rpc/:operation, with the empty service (CLI-28)
80
+ const single = path.match(/^\/rpc\/([^/]+)\/?$/);
81
+ if (single?.[1]) {
82
+ return { service: "", operation: single[1] };
83
+ }
84
+
78
85
  // Match pattern: /rpc/:service/:operation
79
86
  const match = path.match(/^\/rpc\/([^/]+)\/([^/]+)/);
80
87
  if (!match) {
@@ -42,7 +42,7 @@ export class HttpServerTransport implements ServerTransport {
42
42
 
43
43
  private app: Express;
44
44
  private httpServer: HttpServer | null = null;
45
- private options: { port: number; host: string; basePath: string; cors: boolean; httpServer: HttpServer | undefined };
45
+ private options: { port: number; host: string; basePath: string; cors: boolean; httpServer: HttpServer | undefined; takesBasePath: boolean };
46
46
  /** The procedure handler that does the work of each request (CLI-3) */
47
47
  private readonly handler: NodeProcedureHandler;
48
48
  /** The CORS of the app, or undefined when it is off */
@@ -58,6 +58,8 @@ export class HttpServerTransport implements ServerTransport {
58
58
  basePath,
59
59
  cors: options.cors ?? false,
60
60
  httpServer: options.httpServer,
61
+ // A single endpoint with the path in the body is the base path itself (CLI-31)
62
+ takesBasePath: options.addressing?.takesBasePath === true,
61
63
  };
62
64
  // Route by URL path so the raw server matches the HTTP client's default URL format instead of
63
65
  // inferring the operation from the verb (BUGS-2026-07 C3, bug 16)
@@ -77,6 +79,7 @@ export class HttpServerTransport implements ServerTransport {
77
79
  allowGet: options.allowGet,
78
80
  secretHeaders: options.secretHeaders,
79
81
  secretQuery: options.secretQuery,
82
+ addressing: options.addressing,
80
83
  },
81
84
  {
82
85
  legacy: true,
@@ -116,9 +119,11 @@ export class HttpServerTransport implements ServerTransport {
116
119
  });
117
120
  }
118
121
 
119
- // Catch-all route for RPC - use regex to match any path under basePath
122
+ // Catch-all route for RPC - use regex to match any path under basePath, and the base path
123
+ // itself for an addressing that takes it
124
+ const base = this.options.basePath.replace(/\//g, "\\/");
120
125
  this.app.all(
121
- new RegExp(`^${this.options.basePath.replace(/\//g, "\\/")}\\/.*`),
126
+ new RegExp(this.options.takesBasePath ? `^${base.replace(/\\\/$/, "")}(?:\\/.*)?$` : `^${base}\\/.*`),
122
127
  async (req, res) => {
123
128
  await this.handler.handle(req, res);
124
129
  }
@@ -10,6 +10,7 @@ import type { Method } from "../../../client/types.js";
10
10
  import type { BrowserGuardOptions } from "../../../server/browser-guard.js";
11
11
  import type { Clock } from "../../../clock.js";
12
12
  import type { Duration } from "../../../duration.js";
13
+ import type { Addressing } from "./addressing.js";
13
14
 
14
15
  /**
15
16
  * URL strategy for converting HTTP requests to RPC methods.
@@ -51,6 +52,13 @@ export interface HttpServerTransportOptions extends BrowserGuardOptions {
51
52
  */
52
53
  urlStrategy?: HttpUrlStrategy;
53
54
 
55
+ /**
56
+ * The addressing of a request (CLI-31): a hook of the request and its body that gives the
57
+ * method and the payload of the call. It replaces `urlStrategy`. With `bodyPathAddressing()`,
58
+ * the route takes the base path itself too.
59
+ */
60
+ addressing?: Addressing;
61
+
54
62
  /**
55
63
  * Base path for RPC endpoints.
56
64
  * @default "/api"
@@ -60,6 +60,13 @@ export interface UrlPattern {
60
60
  * Examples:
61
61
  * - { service: "users", operation: "get" } → "/users/get"
62
62
  * - { service: "users", operation: "get", version: "v2" } → "/v2/users/get"
63
+ * - { service: "", operation: "morning" } → "/morning" (a path of one segment, CLI-28)
64
+ *
65
+ * A path gives one URL segment (a path of one segment) or two (the service with dots, and the
66
+ * operation). Each URL segment is URI-encoded, so a path segment does not make a URL separator.
67
+ * Thus only the version makes three URL segments, and the forms do not collide. A path of one
68
+ * segment has no version in the URL: `/{version}/{operation}` reads as `/{service}/{operation}`.
69
+ * The server ignores the version (wire protocol v1).
63
70
  */
64
71
  export const defaultUrlPattern: UrlPattern = {
65
72
  template: "/{version?}/{service}/{operation}",
@@ -67,11 +74,13 @@ export const defaultUrlPattern: UrlPattern = {
67
74
  format(method: Method, baseUrl: string): string {
68
75
  const parts = [baseUrl.replace(/\/$/, "")];
69
76
 
70
- if (method.version) {
71
- parts.push(method.version);
77
+ // The version needs a service: without one, it reads as the service (CLI-28)
78
+ if (method.version && method.service !== "") {
79
+ parts.push(encodeURIComponent(method.version));
72
80
  }
73
81
 
74
- parts.push(method.service, method.operation);
82
+ if (method.service !== "") parts.push(encodeURIComponent(method.service));
83
+ parts.push(encodeURIComponent(method.operation));
75
84
 
76
85
  return parts.join("/");
77
86
  },
@@ -79,11 +88,11 @@ export const defaultUrlPattern: UrlPattern = {
79
88
  parse(url: string, _httpMethod: HTTPMethod): Method | null {
80
89
  // Remove base URL and query params
81
90
  const path = url.split("?")[0];
82
- const segments = path?.split("/").filter(Boolean) ?? [];
91
+ const segments = (path?.split("/").filter(Boolean) ?? []).map(decodeSegment);
83
92
 
84
- // Minimum: /service/operation (2 segments)
85
- if (segments.length < 2) {
86
- return null;
93
+ // A path of one segment: /operation, with the empty service (CLI-28)
94
+ if (segments.length === 1) {
95
+ return { service: "", operation: segments[0]! };
87
96
  }
88
97
 
89
98
  // Pattern: [version?]/service/operation
@@ -106,6 +115,15 @@ export const defaultUrlPattern: UrlPattern = {
106
115
  },
107
116
  };
108
117
 
118
+ /** A URL segment without its URI encoding. A segment that is not valid URI encoding stays as it is. */
119
+ function decodeSegment(segment: string): string {
120
+ try {
121
+ return decodeURIComponent(segment);
122
+ } catch {
123
+ return segment;
124
+ }
125
+ }
126
+
109
127
  /**
110
128
  * RESTful URL Pattern: /{version?}/{service}/{id?}
111
129
  *
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Message, Method } from "../../../client/types.js";
8
+ import { methodName } from "../../../server/method.js";
8
9
  import type { ProcedureRegistry } from "../../../procedures/registry.js";
9
10
  import type { Interceptor } from "../../../procedures/invoke.js";
10
11
  import type { Capability, ClearanceOrder } from "../../../procedures/types.js";
@@ -23,7 +24,8 @@ export type Handler<TReq = unknown, TRes = unknown> = (
23
24
  */
24
25
  export function methodKey(method: Method): string {
25
26
  const version = method.version || "";
26
- return `${version}:${method.service}.${method.operation}`;
27
+ // A path of one segment has the key of its operation: "morning", not ".morning" (CLI-28)
28
+ return `${version}:${methodName(method)}`;
27
29
  }
28
30
 
29
31
  /**
package/src/browser.ts CHANGED
@@ -87,7 +87,7 @@ export {
87
87
  HandlerNotFoundError,
88
88
  ServerError,
89
89
  } from "./server/types.js";
90
- export { pathToMethod, methodToPath } from "./server/method.js";
90
+ export { pathToMethod, methodToPath, methodName } from "./server/method.js";
91
91
  export type {
92
92
  ServerRequest,
93
93
  ServerResponse,
@@ -127,6 +127,7 @@ export {
127
127
  // The partitions of a collection (roadmap CLI-17)
128
128
  PARTITION_PATTERN,
129
129
  collectionTopicPartition,
130
+ collectionTopicTarget,
130
131
  // The query language of the collection storages (roadmap CLI-14, CLI-34)
131
132
  DEFAULT_FIND_LIMIT,
132
133
  DEFAULT_QUERY_OPERATORS,
@@ -379,6 +380,10 @@ export type { ProtocolErrorCode, ErrorCodeInfo } from "./protocol.js";
379
380
  export { STREAM_GAP_METADATA_KEY, STREAM_ITEM_METADATA_KEY } from "./adapters/sequence.js";
380
381
  // The deadline of a call: the remaining time goes as procedure-timeout (roadmap R10)
381
382
  export { TIMEOUT_METADATA_KEY, DEADLINE_METADATA_KEY } from "./client/deadline.js";
383
+ // The narrowing key of a call: the call asks for a narrower capability on each face (CLI-30)
384
+ export { NARROWING_METADATA_KEY, NARROWING_HEADER, narrowingOf, narrowWithin, addNarrowing } from "./procedures/narrowing.js";
385
+ export type { NarrowingNote, WireNarrowing, NarrowedCapability } from "./procedures/narrowing.js";
386
+ export type { ServerDebugReport } from "./server/types.js";
382
387
  // Typed durations: each public option that is a time takes a Duration (roadmap R11)
383
388
  export { ms, seconds, minutes, hours, toMs, optionalMs, isDuration } from "./duration.js";
384
389
  export type { Duration, TimeUnit } from "./duration.js";
@@ -171,13 +171,20 @@ export interface ExecOptions {
171
171
  * (`observeHydration()` gets the events of every program in the process.)
172
172
  */
173
173
  onRef?: ((event: HydrationEvent) => void) | undefined;
174
+ /**
175
+ * The metadata of the program. A local procedure gets it as `ctx.metadata`, and a remote call
176
+ * sends it. The narrowing key `procedure-narrowing` narrows the capability of each call of the
177
+ * program, local or remote (CLI-30).
178
+ */
179
+ metadata?: Record<string, unknown> | undefined;
174
180
  }
175
181
 
176
- /** The caller context of `exec()` and `execStream()`: the observer and the signal. */
182
+ /** The caller context of `exec()` and `execStream()`: the observer, the signal and the metadata. */
177
183
  function callerOf(options: ExecOptions): CallerContext {
178
184
  const caller: CallerContext = {};
179
185
  if (options.onRef) caller.onRef = options.onRef;
180
186
  if (options.signal) caller.signal = options.signal;
187
+ if (options.metadata) caller.metadata = options.metadata;
181
188
  return caller;
182
189
  }
183
190
 
@@ -13,6 +13,7 @@ import {
13
13
  import { getErrorMetadata } from "./registry.js";
14
14
  import { httpStatusToErrorCode } from "./http-errors.js";
15
15
  import { ErrorCode } from "./codes.js";
16
+ import { methodName } from "../../server/method.js";
16
17
 
17
18
  /**
18
19
  * Create a rich error from an error code.
@@ -226,9 +227,7 @@ export function formatError(error: RichError, includeStack = false): string {
226
227
 
227
228
  if (error.context.method) {
228
229
  const m = error.context.method;
229
- const methodStr = m.version
230
- ? `${m.version}:${m.service}.${m.operation}`
231
- : `${m.service}.${m.operation}`;
230
+ const methodStr = m.version ? `${m.version}:${methodName(m)}` : methodName(m);
232
231
  lines.push(` Method: ${methodStr}`);
233
232
  }
234
233
 
@@ -11,6 +11,7 @@ import { LruMap } from "./lru.js";
11
11
  import { isDuration, optionalMs, type Duration } from "../../duration.js";
12
12
  import type { Effects } from "../../procedures/types.js";
13
13
  import { TIMER_DEFAULTS } from "../../timers.js";
14
+ import { methodName } from "../../server/method.js";
14
15
 
15
16
  /**
16
17
  * Cache middleware options.
@@ -287,7 +288,7 @@ export function createCacheMiddleware(options: CacheOptions): TypedClientMiddlew
287
288
  const cacheable =
288
289
  typeof methods === "function"
289
290
  ? methods
290
- : ((names: ReadonlySet<string>) => (method: Method): boolean => names.has(`${method.service}.${method.operation}`))(
291
+ : ((names: ReadonlySet<string>) => (method: Method): boolean => names.has(methodName(method)))(
291
292
  new Set(methods),
292
293
  );
293
294
  const effectsOf = options.effects;
@@ -29,7 +29,7 @@ import { createProcedure } from "../procedures/define.js";
29
29
  import { InvocationError } from "../procedures/invoke.js";
30
30
  import { capabilityRefusal } from "../procedures/capability.js";
31
31
  import { collectionChangeTopic, collectionTopicReads } from "../procedures/collection/changes.js";
32
- import { collectionTopicPartition, partitionOfCall } from "../procedures/collection/partition.js";
32
+ import { collectionTopicTarget, partitionOfCall } from "../procedures/collection/partition.js";
33
33
  import type { AnyProcedure, Capability, ProcedureContext } from "../procedures/types.js";
34
34
  import type { Principal } from "../server/types.js";
35
35
  import type { EventBus, Unsubscribe } from "./types.js";
@@ -81,7 +81,12 @@ export interface EventPublisherOptions {
81
81
  * The resources that a subscriber of a topic reads (roadmap CLI-15). The publisher refuses a
82
82
  * topic whose reads the capability of the call does not permit, as for a procedure that reads
83
83
  * them: `FORBIDDEN` with the reason `effects-exceeded`.
84
- * @default `collectionTopicReads`: a topic `collections.<name>` reads `collection:<name>`
84
+ *
85
+ * A custom function gets only the topic. For a topic of a collection, give the read of the
86
+ * collection that the publisher listens to: the collection of `collectionTopicTarget(topic,
87
+ * repository)` with the repository of the host. A function that reads the first part of the
88
+ * topic alone checks `collection:a` for the dotted collection `a.b`.
89
+ * @default the read of the collection of `collectionTopicTarget()`, with the repository of the call
85
90
  */
86
91
  reads?: ((topic: string) => readonly string[]) | undefined;
87
92
  }
@@ -309,6 +314,8 @@ export class EventPublisher {
309
314
  private readonly known: (topic: string) => boolean;
310
315
  private readonly allow: EventPublisherOptions["allow"];
311
316
  private readonly reads: (topic: string) => readonly string[];
317
+ /** True when the reads come from the topic rule of the collections (no option `reads`) */
318
+ private readonly defaultReads: boolean;
312
319
  private readonly buffer: number;
313
320
  private readonly open = new Set<TopicSubscription>();
314
321
 
@@ -317,6 +324,7 @@ export class EventPublisher {
317
324
  this.known = topicMatcher(options.topics);
318
325
  this.allow = options.allow;
319
326
  this.reads = options.reads ?? collectionTopicReads;
327
+ this.defaultReads = options.reads === undefined;
320
328
  this.buffer = Math.max(1, Math.floor(options.buffer ?? 1024));
321
329
  this.procedure = createProcedure()
322
330
  .path([...EVENTS_SUBSCRIBE_PATH])
@@ -356,10 +364,21 @@ export class EventPublisher {
356
364
  * another tenant. A collection without partitions refuses a topic with a partition.
357
365
  */
358
366
  private async busTopic(topic: string, ctx: ProcedureContext): Promise<string> {
359
- const parts = collectionTopicPartition(topic);
360
- if (!parts) return topic;
361
- const partition = await partitionOfCall(ctx, parts.collection, parts.partition);
362
- return collectionChangeTopic(parts.collection, partition);
367
+ const target = collectionTopicTarget(topic, ctx.repository);
368
+ if (!target) return topic;
369
+ const partition = await partitionOfCall(ctx, target.collection, target.partition);
370
+ return collectionChangeTopic(target.collection, partition);
371
+ }
372
+
373
+ /**
374
+ * The resources that a subscriber of a topic reads. The default reads the collection of the
375
+ * topic with the rule of the listener (`collectionTopicTarget`), so the read check and the
376
+ * listener name the same collection.
377
+ */
378
+ private readsOf(topic: string, ctx: ProcedureContext): readonly string[] {
379
+ if (!this.defaultReads) return this.reads(topic);
380
+ const target = collectionTopicTarget(topic, ctx.repository);
381
+ return target ? [`collection:${target.collection}`] : [];
363
382
  }
364
383
 
365
384
  /**
@@ -373,7 +392,7 @@ export class EventPublisher {
373
392
  }
374
393
  // A subscriber of a topic reads its resources: the capability of the call must permit them
375
394
  for (const topic of unique) {
376
- const reads = this.reads(topic);
395
+ const reads = this.readsOf(topic, ctx);
377
396
  if (reads.length === 0) continue;
378
397
  const refusal = capabilityRefusal(topicReader(reads), ctx.capability);
379
398
  if (refusal) throw new InvocationError("FORBIDDEN", `The topic "${topic}": ${refusal.message}`, ctx.path, refusal.reason);
package/src/node.ts CHANGED
@@ -14,6 +14,10 @@
14
14
  * gives a request handler `(req, res) => boolean`, and `upgradeHandler(server, options)` gives an
15
15
  * upgrade handler `(req, socket, head) => boolean`. Each one takes the requests of its path and
16
16
  * gives `false` for the others, so the routes of the application share the listener.
17
+ *
18
+ * The option `addressing` of `procedureHandler` gives the method and the payload of a request
19
+ * (CLI-31). `urlAddressing()` is the default, and `bodyPathAddressing()` reads the path from the
20
+ * body of a single endpoint.
17
21
  */
18
22
 
19
23
  export { WebSocketTransport } from "./adapters/websocket/client/node.js";
@@ -25,6 +29,9 @@ export type { ProcedureHandlerOptions, NodeProcedureHandler, NodeUrlStrategy, Ur
25
29
  export type { CorsOptions } from "./adapters/http/server/cors.js";
26
30
  export type { RoutedRequest } from "./adapters/http/server/strategies.js";
27
31
  export { defaultServerUrlStrategy, rpcServerUrlStrategy } from "./adapters/http/server/strategies.js";
32
+ // The addressing of a request: the URL (the default), or the path in the body of a single endpoint (CLI-31)
33
+ export { urlAddressing, bodyPathAddressing } from "./adapters/http/server/addressing.js";
34
+ export type { Addressing, CallAddress, UrlAddressingOptions, BodyPathAddressingOptions } from "./adapters/http/server/addressing.js";
28
35
  export { upgradeHandler } from "./adapters/websocket/server/upgrade.js";
29
36
  export type { UpgradeHandlerOptions, NodeUpgradeHandler } from "./adapters/websocket/server/upgrade.js";
30
37
  export { WebSocketServerTransport } from "./adapters/websocket/server/transport.js";
@@ -71,7 +71,7 @@ export { collectionChangeTopic, collectionTopicReads } from "./changes.js";
71
71
  export type { CollectionChangeEvent } from "./changes.js";
72
72
 
73
73
  // The partitions of a collection (roadmap CLI-17)
74
- export { PARTITION_PATTERN, collectionTopicPartition, partitionOfCall, storageOfCall, withPartition } from "./partition.js";
74
+ export { PARTITION_PATTERN, collectionTopicPartition, collectionTopicTarget, partitionOfCall, storageOfCall, withPartition } from "./partition.js";
75
75
  export type { CollectionPartitions, PartitionInput, PartitionResolver } from "./partition.js";
76
76
 
77
77
  // The storage interface of the core (the shape of client-collections' CollectionStorage)
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import type { ZodLike } from "../../client/validation/types.js";
17
17
  import { InvocationError } from "../invoke.js";
18
- import type { ProcedureContext } from "../types.js";
18
+ import type { ProcedureContext, RepositoryProvider } from "../types.js";
19
19
  import type { CollectionStorage } from "./storage.js";
20
20
 
21
21
  /** The characters of a partition: letters, digits and `_ . : @ ~ + | / -`, 256 at most. */
@@ -31,6 +31,11 @@ export interface PartitionInput {
31
31
  * partition that the input asks for. It gives the partition, `undefined` for the storage of the
32
32
  * collection without a partition, or `null` to refuse the call (`FORBIDDEN`, reason
33
33
  * `partition-not-allowed`). An error that it throws passes through.
34
+ *
35
+ * The contract: the resolver gives a partition of the caller, or `null`. It never gives the
36
+ * partition of another tenant. A resolver that maps a request of tenant B to the partition of
37
+ * tenant A is a defect of the app: the events of A then reach B under the topic that B named, and
38
+ * a `HybridStorage` of B keeps the items of A.
34
39
  */
35
40
  export type PartitionResolver = (ctx: ProcedureContext, requested: string | undefined) => string | undefined | null | Promise<string | undefined | null>;
36
41
 
@@ -112,9 +117,26 @@ export function storageOfCall<T>(ctx: ProcedureContext, collection: string, part
112
117
  return repository.getPartitionStorage<T>(collection, partition);
113
118
  }
114
119
 
120
+ /**
121
+ * This function gives the collection and the partition part of a topic of a call, with the
122
+ * repository of the call: one rule for the read check and for the listener of the publisher.
123
+ *
124
+ * - A collection of the repository without partitions whose name is the whole rest of the topic
125
+ * wins: its name can have a dot.
126
+ * - Else the first part after `collections.` names the collection, and the rest is the partition
127
+ * that the call asks for. A collection with partitions has no dot in its name.
128
+ */
129
+ export function collectionTopicTarget(topic: string, repository: RepositoryProvider | undefined): { collection: string; partition: string | undefined } | undefined {
130
+ if (!topic.startsWith("collections.")) return undefined;
131
+ const whole = topic.slice("collections.".length);
132
+ if (whole !== "" && repository?.hasCollection(whole) && !repository.hasPartitions?.(whole)) return { collection: whole, partition: undefined };
133
+ return collectionTopicPartition(topic);
134
+ }
135
+
115
136
  /**
116
137
  * This function gives the collection and the partition part of a topic `collections.<name>` or
117
- * `collections.<name>.<partition>`, or undefined for another topic.
138
+ * `collections.<name>.<partition>`, or undefined for another topic. It reads the topic alone:
139
+ * `collectionTopicTarget` also knows the collections of a repository.
118
140
  */
119
141
  export function collectionTopicPartition(topic: string): { collection: string; partition: string | undefined } | undefined {
120
142
  const match = /^collections\.([^.]+)(?:\.(.+))?$/.exec(topic);
@@ -107,6 +107,9 @@ export type { ToolHintOverride } from "./hints.js";
107
107
  export { untrustedText, untrustedFieldsOf, untrustedTextsOf } from "./untrusted.js";
108
108
  export type { UntrustedText, UntrustedSource, UntrustedValue } from "./untrusted.js";
109
109
  export type { ToolHints, CapabilityRefusal, CapabilityRefusalReason, InvocationExplanation, EffectsViolation } from "./capability.js";
110
+ // The narrowing key of a call (CLI-30)
111
+ export { NARROWING_METADATA_KEY, NARROWING_HEADER, narrowingOf, narrowWithin, addNarrowing } from "./narrowing.js";
112
+ export type { NarrowingNote, WireNarrowing, NarrowedCapability } from "./narrowing.js";
110
113
 
111
114
  // Registry
112
115
  export { ProcedureRegistry, RegistryError, PROCEDURE_REGISTRY, assertValidPath } from "./registry.js";
@@ -129,6 +132,7 @@ export {
129
132
  collectionTopicReads,
130
133
  PARTITION_PATTERN,
131
134
  collectionTopicPartition,
135
+ collectionTopicTarget,
132
136
  DEFAULT_FIND_LIMIT,
133
137
  DEFAULT_QUERY_OPERATORS,
134
138
  DEFAULT_QUERY_POLICY,