@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.
- package/CHANGELOG.md +40 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/server/addressing.d.ts +78 -0
- package/dist/adapters/http/server/addressing.d.ts.map +1 -0
- package/dist/adapters/http/server/addressing.js +62 -0
- package/dist/adapters/http/server/addressing.js.map +1 -0
- package/dist/adapters/http/server/cors.d.ts.map +1 -1
- package/dist/adapters/http/server/cors.js +3 -0
- package/dist/adapters/http/server/cors.js.map +1 -1
- package/dist/adapters/http/server/handler.d.ts +9 -1
- package/dist/adapters/http/server/handler.d.ts.map +1 -1
- package/dist/adapters/http/server/handler.js +49 -23
- package/dist/adapters/http/server/handler.js.map +1 -1
- package/dist/adapters/http/server/strategies.d.ts +1 -0
- package/dist/adapters/http/server/strategies.d.ts.map +1 -1
- package/dist/adapters/http/server/strategies.js +6 -0
- package/dist/adapters/http/server/strategies.js.map +1 -1
- package/dist/adapters/http/server/transport.d.ts.map +1 -1
- package/dist/adapters/http/server/transport.js +7 -2
- package/dist/adapters/http/server/transport.js.map +1 -1
- package/dist/adapters/http/server/types.d.ts +7 -0
- package/dist/adapters/http/server/types.d.ts.map +1 -1
- package/dist/adapters/http/shared/strategies.d.ts +7 -0
- package/dist/adapters/http/shared/strategies.d.ts.map +1 -1
- package/dist/adapters/http/shared/strategies.js +26 -7
- package/dist/adapters/http/shared/strategies.js.map +1 -1
- package/dist/adapters/local/client/types.d.ts.map +1 -1
- package/dist/adapters/local/client/types.js +3 -1
- package/dist/adapters/local/client/types.js.map +1 -1
- package/dist/browser.d.ts +5 -2
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +4 -2
- package/dist/browser.js.map +1 -1
- package/dist/client/client.d.ts +6 -0
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +3 -1
- package/dist/client/client.js.map +1 -1
- package/dist/client/errors/factory.d.ts.map +1 -1
- package/dist/client/errors/factory.js +2 -3
- package/dist/client/errors/factory.js.map +1 -1
- package/dist/client/middleware/cache.d.ts.map +1 -1
- package/dist/client/middleware/cache.js +2 -1
- package/dist/client/middleware/cache.js.map +1 -1
- package/dist/events/publisher.d.ts +14 -1
- package/dist/events/publisher.d.ts.map +1 -1
- package/dist/events/publisher.js +20 -6
- package/dist/events/publisher.js.map +1 -1
- package/dist/node.d.ts +6 -0
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +6 -0
- package/dist/node.js.map +1 -1
- package/dist/procedures/collection/index.d.ts +1 -1
- package/dist/procedures/collection/index.d.ts.map +1 -1
- package/dist/procedures/collection/index.js +1 -1
- package/dist/procedures/collection/index.js.map +1 -1
- package/dist/procedures/collection/partition.d.ts +21 -2
- package/dist/procedures/collection/partition.d.ts.map +1 -1
- package/dist/procedures/collection/partition.js +19 -1
- package/dist/procedures/collection/partition.js.map +1 -1
- package/dist/procedures/index.d.ts +3 -1
- package/dist/procedures/index.d.ts.map +1 -1
- package/dist/procedures/index.js +3 -1
- package/dist/procedures/index.js.map +1 -1
- package/dist/procedures/invoke.d.ts +13 -0
- package/dist/procedures/invoke.d.ts.map +1 -1
- package/dist/procedures/invoke.js +29 -0
- package/dist/procedures/invoke.js.map +1 -1
- package/dist/procedures/narrowing.d.ts +93 -0
- package/dist/procedures/narrowing.d.ts.map +1 -0
- package/dist/procedures/narrowing.js +328 -0
- package/dist/procedures/narrowing.js.map +1 -0
- package/dist/procedures/types.d.ts +6 -0
- package/dist/procedures/types.d.ts.map +1 -1
- package/dist/procedures/types.js.map +1 -1
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/method.d.ts +8 -2
- package/dist/server/method.d.ts.map +1 -1
- package/dist/server/method.js +16 -5
- package/dist/server/method.js.map +1 -1
- package/dist/server/procedure-server.d.ts +18 -1
- package/dist/server/procedure-server.d.ts.map +1 -1
- package/dist/server/procedure-server.js +31 -2
- package/dist/server/procedure-server.js.map +1 -1
- package/dist/server/types.d.ts +16 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/types.js +2 -1
- package/dist/server/types.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/http/server/addressing.ts +118 -0
- package/src/adapters/http/server/cors.ts +3 -0
- package/src/adapters/http/server/handler.ts +57 -23
- package/src/adapters/http/server/strategies.ts +7 -0
- package/src/adapters/http/server/transport.ts +8 -3
- package/src/adapters/http/server/types.ts +8 -0
- package/src/adapters/http/shared/strategies.ts +25 -7
- package/src/adapters/local/client/types.ts +3 -1
- package/src/browser.ts +6 -1
- package/src/client/client.ts +8 -1
- package/src/client/errors/factory.ts +2 -3
- package/src/client/middleware/cache.ts +2 -1
- package/src/events/publisher.ts +26 -7
- package/src/node.ts +7 -0
- package/src/procedures/collection/index.ts +1 -1
- package/src/procedures/collection/partition.ts +24 -2
- package/src/procedures/index.ts +4 -0
- package/src/procedures/invoke.ts +38 -0
- package/src/procedures/narrowing.ts +357 -0
- package/src/procedures/types.ts +7 -0
- package/src/server/index.ts +2 -1
- package/src/server/method.ts +17 -5
- package/src/server/procedure-server.ts +42 -2
- 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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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 =>
|
|
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
|
-
/**
|
|
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
|
|
355
|
-
|
|
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
|
|
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
|
|
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
|
|
430
|
+
if (!RESERVED_METADATA.has(key) && key !== "auth" && !key.startsWith("__")) setMetadata(metadata, key, value);
|
|
395
431
|
}
|
|
396
432
|
}
|
|
397
433
|
|
|
398
|
-
// The payload
|
|
399
|
-
// false, "" and null stay (deep dive TRN-10)
|
|
400
|
-
|
|
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(
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
//
|
|
85
|
-
if (segments.length
|
|
86
|
-
return
|
|
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
|
-
|
|
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";
|
package/src/client/client.ts
CHANGED
|
@@ -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
|
|
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(
|
|
291
|
+
: ((names: ReadonlySet<string>) => (method: Method): boolean => names.has(methodName(method)))(
|
|
291
292
|
new Set(methods),
|
|
292
293
|
);
|
|
293
294
|
const effectsOf = options.effects;
|
package/src/events/publisher.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
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
|
|
360
|
-
if (!
|
|
361
|
-
const partition = await partitionOfCall(ctx,
|
|
362
|
-
return collectionChangeTopic(
|
|
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.
|
|
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);
|
package/src/procedures/index.ts
CHANGED
|
@@ -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,
|