@procedures/client 0.8.1 → 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 (129) hide show
  1. package/CHANGELOG.md +60 -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/adapters/websocket/client/transport.d.ts.map +1 -1
  31. package/dist/adapters/websocket/client/transport.js +4 -12
  32. package/dist/adapters/websocket/client/transport.js.map +1 -1
  33. package/dist/browser.d.ts +6 -3
  34. package/dist/browser.d.ts.map +1 -1
  35. package/dist/browser.js +5 -1
  36. package/dist/browser.js.map +1 -1
  37. package/dist/client/client.d.ts +6 -0
  38. package/dist/client/client.d.ts.map +1 -1
  39. package/dist/client/client.js +3 -1
  40. package/dist/client/client.js.map +1 -1
  41. package/dist/client/errors/factory.d.ts.map +1 -1
  42. package/dist/client/errors/factory.js +2 -3
  43. package/dist/client/errors/factory.js.map +1 -1
  44. package/dist/client/middleware/cache.d.ts.map +1 -1
  45. package/dist/client/middleware/cache.js +2 -1
  46. package/dist/client/middleware/cache.js.map +1 -1
  47. package/dist/events/publisher.d.ts +25 -2
  48. package/dist/events/publisher.d.ts.map +1 -1
  49. package/dist/events/publisher.js +42 -9
  50. package/dist/events/publisher.js.map +1 -1
  51. package/dist/node.d.ts +6 -0
  52. package/dist/node.d.ts.map +1 -1
  53. package/dist/node.js +6 -0
  54. package/dist/node.js.map +1 -1
  55. package/dist/procedures/collection/changes.d.ts +3 -3
  56. package/dist/procedures/collection/changes.d.ts.map +1 -1
  57. package/dist/procedures/collection/changes.js +4 -4
  58. package/dist/procedures/collection/changes.js.map +1 -1
  59. package/dist/procedures/collection/index.d.ts +2 -0
  60. package/dist/procedures/collection/index.d.ts.map +1 -1
  61. package/dist/procedures/collection/index.js +2 -0
  62. package/dist/procedures/collection/index.js.map +1 -1
  63. package/dist/procedures/collection/partition.d.ts +86 -0
  64. package/dist/procedures/collection/partition.d.ts.map +1 -0
  65. package/dist/procedures/collection/partition.js +100 -0
  66. package/dist/procedures/collection/partition.js.map +1 -0
  67. package/dist/procedures/collection/procedures.d.ts +4 -4
  68. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  69. package/dist/procedures/collection/procedures.js +55 -55
  70. package/dist/procedures/collection/procedures.js.map +1 -1
  71. package/dist/procedures/index.d.ts +4 -2
  72. package/dist/procedures/index.d.ts.map +1 -1
  73. package/dist/procedures/index.js +3 -1
  74. package/dist/procedures/index.js.map +1 -1
  75. package/dist/procedures/invoke.d.ts +17 -2
  76. package/dist/procedures/invoke.d.ts.map +1 -1
  77. package/dist/procedures/invoke.js +30 -1
  78. package/dist/procedures/invoke.js.map +1 -1
  79. package/dist/procedures/narrowing.d.ts +93 -0
  80. package/dist/procedures/narrowing.d.ts.map +1 -0
  81. package/dist/procedures/narrowing.js +328 -0
  82. package/dist/procedures/narrowing.js.map +1 -0
  83. package/dist/procedures/types.d.ts +19 -0
  84. package/dist/procedures/types.d.ts.map +1 -1
  85. package/dist/procedures/types.js.map +1 -1
  86. package/dist/server/index.d.ts +2 -2
  87. package/dist/server/index.d.ts.map +1 -1
  88. package/dist/server/index.js +1 -1
  89. package/dist/server/index.js.map +1 -1
  90. package/dist/server/method.d.ts +8 -2
  91. package/dist/server/method.d.ts.map +1 -1
  92. package/dist/server/method.js +16 -5
  93. package/dist/server/method.js.map +1 -1
  94. package/dist/server/procedure-server.d.ts +47 -1
  95. package/dist/server/procedure-server.d.ts.map +1 -1
  96. package/dist/server/procedure-server.js +89 -12
  97. package/dist/server/procedure-server.js.map +1 -1
  98. package/dist/server/types.d.ts +16 -0
  99. package/dist/server/types.d.ts.map +1 -1
  100. package/dist/server/types.js +2 -1
  101. package/dist/server/types.js.map +1 -1
  102. package/package.json +3 -3
  103. package/src/adapters/http/server/addressing.ts +118 -0
  104. package/src/adapters/http/server/cors.ts +3 -0
  105. package/src/adapters/http/server/handler.ts +57 -23
  106. package/src/adapters/http/server/strategies.ts +7 -0
  107. package/src/adapters/http/server/transport.ts +8 -3
  108. package/src/adapters/http/server/types.ts +8 -0
  109. package/src/adapters/http/shared/strategies.ts +25 -7
  110. package/src/adapters/local/client/types.ts +3 -1
  111. package/src/adapters/websocket/client/transport.ts +4 -12
  112. package/src/browser.ts +12 -1
  113. package/src/client/client.ts +8 -1
  114. package/src/client/errors/factory.ts +2 -3
  115. package/src/client/middleware/cache.ts +2 -1
  116. package/src/events/publisher.ts +50 -11
  117. package/src/node.ts +7 -0
  118. package/src/procedures/collection/changes.ts +4 -4
  119. package/src/procedures/collection/index.ts +4 -0
  120. package/src/procedures/collection/partition.ts +145 -0
  121. package/src/procedures/collection/procedures.ts +72 -73
  122. package/src/procedures/index.ts +9 -0
  123. package/src/procedures/invoke.ts +42 -2
  124. package/src/procedures/narrowing.ts +357 -0
  125. package/src/procedures/types.ts +23 -0
  126. package/src/server/index.ts +2 -1
  127. package/src/server/method.ts +17 -5
  128. package/src/server/procedure-server.ts +106 -13
  129. 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
  /**
@@ -578,18 +578,10 @@ export class WebSocketTransport implements Transport {
578
578
  return;
579
579
  }
580
580
 
581
- // The frame type `event` is reserved: no side of protocol v1 sends it. A server outside this
582
- // repository can send it, so the frame still goes to onEvent (EVENTS.md).
583
- if (message.type === "event") {
584
- if (this.options.onEvent && message.topic && message.subscriptionId) {
585
- try {
586
- this.options.onEvent(message.topic, message.data, message.subscriptionId);
587
- } catch (error) {
588
- console.error(`[${this.name}] Event handler error:`, error);
589
- }
590
- }
591
- return;
592
- }
581
+ // The frame type `event` is reserved: no side of protocol v1 sends it. The client ignores it,
582
+ // as a frame of an unknown type. Before 0.9.0, it gave the frame to onEvent, for a server
583
+ // outside this repository (EVENTS.md). onEvent gets the events of the subscriptions only.
584
+ if (message.type === "event") return;
593
585
 
594
586
  // Handle response
595
587
  const pending = this.pendingRequests.get(message.id);
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,
@@ -124,6 +124,10 @@ export {
124
124
  // The change events of the collection writes (roadmap CLI-15)
125
125
  collectionChangeTopic,
126
126
  collectionTopicReads,
127
+ // The partitions of a collection (roadmap CLI-17)
128
+ PARTITION_PATTERN,
129
+ collectionTopicPartition,
130
+ collectionTopicTarget,
127
131
  // The query language of the collection storages (roadmap CLI-14, CLI-34)
128
132
  DEFAULT_FIND_LIMIT,
129
133
  DEFAULT_QUERY_OPERATORS,
@@ -275,6 +279,9 @@ export type {
275
279
  StorageQueryPolicy,
276
280
  CollectionProceduresOptions,
277
281
  CollectionChangeEventsOptions,
282
+ CollectionPartitions,
283
+ PartitionInput,
284
+ PartitionResolver,
278
285
  CollectionChangeEvent,
279
286
  ProcedureModule,
280
287
  ModuleInput,
@@ -373,6 +380,10 @@ export type { ProtocolErrorCode, ErrorCodeInfo } from "./protocol.js";
373
380
  export { STREAM_GAP_METADATA_KEY, STREAM_ITEM_METADATA_KEY } from "./adapters/sequence.js";
374
381
  // The deadline of a call: the remaining time goes as procedure-timeout (roadmap R10)
375
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";
376
387
  // Typed durations: each public option that is a time takes a Duration (roadmap R11)
377
388
  export { ms, seconds, minutes, hours, toMs, optionalMs, isDuration } from "./duration.js";
378
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;
@@ -28,8 +28,10 @@ import { z } from "zod";
28
28
  import { createProcedure } from "../procedures/define.js";
29
29
  import { InvocationError } from "../procedures/invoke.js";
30
30
  import { capabilityRefusal } from "../procedures/capability.js";
31
- import { collectionTopicReads } from "../procedures/collection/changes.js";
31
+ import { collectionChangeTopic, collectionTopicReads } from "../procedures/collection/changes.js";
32
+ import { collectionTopicTarget, partitionOfCall } from "../procedures/collection/partition.js";
32
33
  import type { AnyProcedure, Capability, ProcedureContext } from "../procedures/types.js";
34
+ import type { Principal } from "../server/types.js";
33
35
  import type { EventBus, Unsubscribe } from "./types.js";
34
36
  import { getGlobalEventBus } from "./bus.js";
35
37
  import { EVENTS_SUBSCRIBE_PATH, type EventItem, type SubscribeInput } from "./remote.js";
@@ -53,6 +55,8 @@ export interface TopicCall {
53
55
  readonly capability: Capability;
54
56
  /** The metadata of the call: the connection id and the headers of a network request. */
55
57
  readonly metadata: Readonly<Record<string, unknown>>;
58
+ /** The principal of the call, from the authentication of the host. */
59
+ readonly principal?: Principal | undefined;
56
60
  }
57
61
 
58
62
  /** The options of an `EventPublisher`. */
@@ -77,7 +81,12 @@ export interface EventPublisherOptions {
77
81
  * The resources that a subscriber of a topic reads (roadmap CLI-15). The publisher refuses a
78
82
  * topic whose reads the capability of the call does not permit, as for a procedure that reads
79
83
  * them: `FORBIDDEN` with the reason `effects-exceeded`.
80
- * @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
81
90
  */
82
91
  reads?: ((topic: string) => readonly string[]) | undefined;
83
92
  }
@@ -161,17 +170,18 @@ class TopicSubscription implements AsyncIterableIterator<EventItem> {
161
170
 
162
171
  constructor(
163
172
  bus: EventBus,
164
- topics: readonly string[],
173
+ /** The topics of the subscriber, each with the topic of the bus that it listens to */
174
+ routes: ReadonlyArray<readonly [topic: string, busTopic: string]>,
165
175
  private readonly buffer: number,
166
176
  private readonly signal: AbortSignal | undefined,
167
177
  private readonly onClose: (subscription: TopicSubscription) => void,
168
178
  /** The output filter of the host for this subscriber: each row of a value passes it */
169
179
  private readonly admit: ((item: unknown) => Promise<boolean>) | undefined,
170
180
  ) {
171
- this.confirmation = { type: "subscribed", topics };
181
+ this.confirmation = { type: "subscribed", topics: routes.map(([topic]) => topic) };
172
182
  // Before, clear() removed the listeners, and the subscriber saw no end (EVENTS.md)
173
- this.unsubscribes = topics.map((topic) =>
174
- bus.on(topic, (data) => this.push(topic, data), { onClear: () => this.fail(clearedError(topic), { keepQueue: true }) }),
183
+ this.unsubscribes = routes.map(([topic, busTopic]) =>
184
+ bus.on(busTopic, (data) => this.push(topic, data), { onClear: () => this.fail(clearedError(topic), { keepQueue: true }) }),
175
185
  );
176
186
  signal?.addEventListener("abort", this.onAbort, { once: true });
177
187
  }
@@ -304,6 +314,8 @@ export class EventPublisher {
304
314
  private readonly known: (topic: string) => boolean;
305
315
  private readonly allow: EventPublisherOptions["allow"];
306
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;
307
319
  private readonly buffer: number;
308
320
  private readonly open = new Set<TopicSubscription>();
309
321
 
@@ -312,6 +324,7 @@ export class EventPublisher {
312
324
  this.known = topicMatcher(options.topics);
313
325
  this.allow = options.allow;
314
326
  this.reads = options.reads ?? collectionTopicReads;
327
+ this.defaultReads = options.reads === undefined;
315
328
  this.buffer = Math.max(1, Math.floor(options.buffer ?? 1024));
316
329
  this.procedure = createProcedure()
317
330
  .path([...EVENTS_SUBSCRIBE_PATH])
@@ -344,31 +357,57 @@ export class EventPublisher {
344
357
  for (const subscription of [...this.open]) subscription.fail(stoppedError());
345
358
  }
346
359
 
360
+ /**
361
+ * The topic of the bus for a topic of a subscriber (roadmap CLI-17). A topic of a partitioned
362
+ * collection names the partition that the subscriber asks for, as the input of a procedure does:
363
+ * the resolver of the repository gives the partition, so a subscriber cannot name the topic of
364
+ * another tenant. A collection without partitions refuses a topic with a partition.
365
+ */
366
+ private async busTopic(topic: string, ctx: ProcedureContext): Promise<string> {
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}`] : [];
382
+ }
383
+
347
384
  /**
348
385
  * The stream of one subscription. The publisher examines each topic before it adds a listener:
349
- * the topic rule first, then `allow`. A call with one bad topic makes no subscription.
386
+ * the topic rule, the reads, `allow`, and the partition. A call with one bad topic makes no subscription.
350
387
  */
351
- private subscribe(topics: readonly string[], ctx: ProcedureContext): TopicSubscription {
388
+ private async subscribe(topics: readonly string[], ctx: ProcedureContext): Promise<TopicSubscription> {
352
389
  const unique = [...new Set(topics)];
353
390
  for (const topic of unique) {
354
391
  if (!this.known(topic)) throw new TopicNotFoundError(`The host has no topic "${topic}"`);
355
392
  }
356
393
  // A subscriber of a topic reads its resources: the capability of the call must permit them
357
394
  for (const topic of unique) {
358
- const reads = this.reads(topic);
395
+ const reads = this.readsOf(topic, ctx);
359
396
  if (reads.length === 0) continue;
360
397
  const refusal = capabilityRefusal(topicReader(reads), ctx.capability);
361
398
  if (refusal) throw new InvocationError("FORBIDDEN", `The topic "${topic}": ${refusal.message}`, ctx.path, refusal.reason);
362
399
  }
363
400
  if (this.allow) {
364
- const call: TopicCall = { capability: ctx.capability, metadata: ctx.metadata };
401
+ const call: TopicCall = { capability: ctx.capability, metadata: ctx.metadata, ...(ctx.principal ? { principal: ctx.principal } : {}) };
365
402
  for (const topic of unique) {
366
403
  if (!this.allow(topic, call)) {
367
404
  throw new InvocationError("FORBIDDEN", `The capability of the call does not permit the topic "${topic}"`, ctx.path, "topic-not-allowed");
368
405
  }
369
406
  }
370
407
  }
371
- const subscription = new TopicSubscription(this.bus, unique, this.buffer, ctx.signal, (closed) => this.open.delete(closed), ctx.admit);
408
+ const routes: Array<readonly [string, string]> = [];
409
+ for (const topic of unique) routes.push([topic, await this.busTopic(topic, ctx)]);
410
+ const subscription = new TopicSubscription(this.bus, routes, this.buffer, ctx.signal, (closed) => this.open.delete(closed), ctx.admit);
372
411
  this.open.add(subscription);
373
412
  return subscription.closeIfAborted();
374
413
  }
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";
@@ -56,11 +56,11 @@ export interface CollectionChangeEvent {
56
56
  }
57
57
 
58
58
  /**
59
- * This function gives the topic of the change events of a collection: `collections.<name>`. The
60
- * partition of roadmap CLI-17 comes after the name, with the option `topic` of the procedures.
59
+ * This function gives the topic of the change events of a collection: `collections.<name>`, or
60
+ * `collections.<name>.<partition>` for a partition (roadmap CLI-17).
61
61
  */
62
- export function collectionChangeTopic(collection: string): string {
63
- return `collections.${collection}`;
62
+ export function collectionChangeTopic(collection: string, partition?: string): string {
63
+ return partition === undefined ? `collections.${collection}` : `collections.${collection}.${partition}`;
64
64
  }
65
65
 
66
66
  /**
@@ -70,6 +70,10 @@ export type { CollectionProceduresOptions, CollectionChangeEventsOptions } from
70
70
  export { collectionChangeTopic, collectionTopicReads } from "./changes.js";
71
71
  export type { CollectionChangeEvent } from "./changes.js";
72
72
 
73
+ // The partitions of a collection (roadmap CLI-17)
74
+ export { PARTITION_PATTERN, collectionTopicPartition, collectionTopicTarget, partitionOfCall, storageOfCall, withPartition } from "./partition.js";
75
+ export type { CollectionPartitions, PartitionInput, PartitionResolver } from "./partition.js";
76
+
73
77
  // The storage interface of the core (the shape of client-collections' CollectionStorage)
74
78
  export type {
75
79
  CollectionStorage,