@procedures/client 0.6.0 → 0.8.0
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 +66 -0
- package/README.md +63 -7
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/client/transport.d.ts +5 -0
- package/dist/adapters/http/client/transport.d.ts.map +1 -1
- package/dist/adapters/http/client/transport.js +10 -4
- package/dist/adapters/http/client/transport.js.map +1 -1
- package/dist/adapters/http/server/ndjson.d.ts +73 -0
- package/dist/adapters/http/server/ndjson.d.ts.map +1 -0
- package/dist/adapters/http/server/ndjson.js +228 -0
- package/dist/adapters/http/server/ndjson.js.map +1 -0
- package/dist/adapters/http/server/transport.d.ts +15 -4
- package/dist/adapters/http/server/transport.d.ts.map +1 -1
- package/dist/adapters/http/server/transport.js +29 -69
- package/dist/adapters/http/server/transport.js.map +1 -1
- package/dist/adapters/http/server/types.d.ts +15 -0
- package/dist/adapters/http/server/types.d.ts.map +1 -1
- package/dist/adapters/sequence.d.ts +14 -0
- package/dist/adapters/sequence.d.ts.map +1 -1
- package/dist/adapters/sequence.js +16 -0
- package/dist/adapters/sequence.js.map +1 -1
- package/dist/adapters/websocket/client/transport.d.ts +7 -3
- package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
- package/dist/adapters/websocket/client/transport.js +9 -3
- package/dist/adapters/websocket/client/transport.js.map +1 -1
- package/dist/adapters/websocket/server/transport.d.ts +10 -0
- package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
- package/dist/adapters/websocket/server/transport.js +22 -2
- package/dist/adapters/websocket/server/transport.js.map +1 -1
- package/dist/browser.d.ts +5 -5
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +4 -3
- package/dist/browser.js.map +1 -1
- package/dist/client/errors/registry.d.ts.map +1 -1
- package/dist/client/errors/registry.js +1 -0
- package/dist/client/errors/registry.js.map +1 -1
- package/dist/client/middleware/retry.d.ts +4 -0
- package/dist/client/middleware/retry.d.ts.map +1 -1
- package/dist/client/middleware/retry.js +86 -78
- package/dist/client/middleware/retry.js.map +1 -1
- package/dist/events/bus.d.ts +32 -14
- package/dist/events/bus.d.ts.map +1 -1
- package/dist/events/bus.js +119 -65
- package/dist/events/bus.js.map +1 -1
- package/dist/events/index.d.ts +3 -2
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js.map +1 -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/events/remote.d.ts +80 -18
- package/dist/events/remote.d.ts.map +1 -1
- package/dist/events/remote.js +448 -58
- package/dist/events/remote.js.map +1 -1
- package/dist/events/types.d.ts +13 -2
- package/dist/events/types.d.ts.map +1 -1
- package/dist/node.d.ts +6 -0
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +5 -0
- package/dist/node.js.map +1 -1
- package/dist/procedures/caller.d.ts +33 -0
- package/dist/procedures/caller.d.ts.map +1 -0
- package/dist/procedures/caller.js +27 -0
- package/dist/procedures/caller.js.map +1 -0
- package/dist/procedures/collection/index.d.ts +2 -2
- 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/procedures.d.ts.map +1 -1
- package/dist/procedures/collection/procedures.js +21 -1
- package/dist/procedures/collection/procedures.js.map +1 -1
- package/dist/procedures/collection/schemas.d.ts +9 -0
- package/dist/procedures/collection/schemas.d.ts.map +1 -1
- package/dist/procedures/collection/schemas.js +28 -0
- package/dist/procedures/collection/schemas.js.map +1 -1
- package/dist/procedures/collection/storage.d.ts +6 -0
- package/dist/procedures/collection/storage.d.ts.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 +2 -0
- package/dist/procedures/index.js.map +1 -1
- package/dist/procedures/invoke.d.ts.map +1 -1
- package/dist/procedures/invoke.js +3 -0
- package/dist/procedures/invoke.js.map +1 -1
- package/dist/procedures/types.d.ts +50 -0
- package/dist/procedures/types.d.ts.map +1 -1
- package/dist/procedures/types.js.map +1 -1
- package/dist/protocol.d.ts +1 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +1 -0
- package/dist/protocol.js.map +1 -1
- package/dist/server/procedure-server.d.ts +41 -5
- package/dist/server/procedure-server.d.ts.map +1 -1
- package/dist/server/procedure-server.js +94 -9
- package/dist/server/procedure-server.js.map +1 -1
- package/dist/timers.d.ts +9 -0
- package/dist/timers.d.ts.map +1 -1
- package/dist/timers.js +6 -1
- package/dist/timers.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/http/client/transport.ts +10 -4
- package/src/adapters/http/server/ndjson.ts +259 -0
- package/src/adapters/http/server/transport.ts +31 -69
- package/src/adapters/http/server/types.ts +17 -0
- package/src/adapters/sequence.ts +18 -0
- package/src/adapters/websocket/client/transport.ts +9 -3
- package/src/adapters/websocket/server/transport.ts +23 -2
- package/src/browser.ts +9 -2
- package/src/client/errors/registry.ts +1 -0
- package/src/client/middleware/retry.ts +89 -78
- package/src/events/bus.ts +141 -73
- package/src/events/index.ts +3 -0
- package/src/events/publisher.ts +23 -6
- package/src/events/remote.ts +575 -73
- package/src/events/types.ts +15 -2
- package/src/node.ts +6 -0
- package/src/procedures/caller.ts +45 -0
- package/src/procedures/collection/index.ts +3 -0
- package/src/procedures/collection/procedures.ts +22 -0
- package/src/procedures/collection/schemas.ts +37 -0
- package/src/procedures/collection/storage.ts +6 -0
- package/src/procedures/index.ts +4 -0
- package/src/procedures/invoke.ts +2 -0
- package/src/procedures/types.ts +53 -0
- package/src/protocol.ts +3 -1
- package/src/server/procedure-server.ts +111 -11
- package/src/timers.ts +15 -1
package/src/events/types.ts
CHANGED
|
@@ -82,9 +82,10 @@ export interface EventBus {
|
|
|
82
82
|
*
|
|
83
83
|
* @param channel - Channel to subscribe to
|
|
84
84
|
* @param handler - Handler function called for each event
|
|
85
|
+
* @param options - `onClear`: the function that runs when `clear()` or `clearAll()` removes the handler
|
|
85
86
|
* @returns Unsubscribe function
|
|
86
87
|
*/
|
|
87
|
-
on<T>(channel: string, handler: EventHandler<T
|
|
88
|
+
on<T>(channel: string, handler: EventHandler<T>, options?: EventListenOptions): Unsubscribe;
|
|
88
89
|
|
|
89
90
|
/**
|
|
90
91
|
* Subscribe to a single event on a channel.
|
|
@@ -177,7 +178,8 @@ export interface TypedEventBus<TChannels extends ChannelMap> {
|
|
|
177
178
|
emit<K extends keyof TChannels & string>(channel: K, data: TChannels[K]): void;
|
|
178
179
|
on<K extends keyof TChannels & string>(
|
|
179
180
|
channel: K,
|
|
180
|
-
handler: EventHandler<TChannels[K]
|
|
181
|
+
handler: EventHandler<TChannels[K]>,
|
|
182
|
+
options?: EventListenOptions
|
|
181
183
|
): Unsubscribe;
|
|
182
184
|
once<K extends keyof TChannels & string>(channel: K, options?: EventWaitOptions): Promise<TChannels[K]>;
|
|
183
185
|
stream<K extends keyof TChannels & string>(channel: K, options?: EventWaitOptions): AsyncIterable<TChannels[K]>;
|
|
@@ -238,6 +240,17 @@ export interface EventWaitOptions {
|
|
|
238
240
|
signal?: AbortSignal | undefined;
|
|
239
241
|
}
|
|
240
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Options for a handler of `on()`.
|
|
245
|
+
*/
|
|
246
|
+
export interface EventListenOptions {
|
|
247
|
+
/**
|
|
248
|
+
* This function runs one time when `clear()` or `clearAll()` removes the handler. An unsubscribe
|
|
249
|
+
* does not run it. The publisher of the remote events uses it to end a subscription with notice.
|
|
250
|
+
*/
|
|
251
|
+
onClear?: (() => void) | undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
241
254
|
/**
|
|
242
255
|
* Options for creating an event bus.
|
|
243
256
|
*/
|
package/src/node.ts
CHANGED
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
* uses the `WebSocket` class of the `ws` package, so a drop ends a socket at once with
|
|
6
6
|
* `terminate()` (finding of roadmap R36). The other entries keep the global `WebSocket`: a browser
|
|
7
7
|
* bundle imports `@procedures/client/browser`, and that entry does not import `ws`.
|
|
8
|
+
*
|
|
9
|
+
* This entry also exports `writeNdjsonStream`: the writer of an NDJSON stream response of wire
|
|
10
|
+
* protocol v1, for a `ServerResponse` of `node:http`. `HttpServerTransport` uses it, so a server
|
|
11
|
+
* without Express writes the same bytes.
|
|
8
12
|
*/
|
|
9
13
|
|
|
10
14
|
export { WebSocketTransport } from "./adapters/websocket/client/node.js";
|
|
11
15
|
export type { WebSocketTransportOptions, WebSocketClass } from "./adapters/websocket/client/types.js";
|
|
16
|
+
export { writeNdjsonStream } from "./adapters/http/server/ndjson.js";
|
|
17
|
+
export type { WriteNdjsonStreamOptions, NdjsonError } from "./adapters/http/server/ndjson.js";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A caller of the procedures of some modules (`callerOf`). An app that calls the procedures of a
|
|
3
|
+
* package in its own process repeats three steps: a registry of the module, `invokePath` and
|
|
4
|
+
* `outputValue`. A caller does the three steps in one call. jarvis had four copies of them.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ProcedurePath } from "./types.js";
|
|
8
|
+
import type { ProcedureRegistry } from "./registry.js";
|
|
9
|
+
import { invokePath, outputValue, type InvokeOptions } from "./invoke.js";
|
|
10
|
+
import { createRegistry, type ModuleInput } from "./module.js";
|
|
11
|
+
|
|
12
|
+
/** The options of one call of a caller: the options of `invokePath` without the registry. */
|
|
13
|
+
export type CallerOptions = Omit<InvokeOptions, "registry">;
|
|
14
|
+
|
|
15
|
+
/** A function that runs the procedures of its modules (`callerOf`). */
|
|
16
|
+
export interface Caller {
|
|
17
|
+
/**
|
|
18
|
+
* This function runs the procedure at `path` with `input`, and gives one value: the value of
|
|
19
|
+
* the procedure, or the last item of its stream. An empty stream rejects with `NO_OUTPUT`, and
|
|
20
|
+
* a path that the modules do not have rejects with `NOT_FOUND`.
|
|
21
|
+
*/
|
|
22
|
+
<T = unknown>(path: ProcedurePath, input: unknown, options?: CallerOptions): Promise<T>;
|
|
23
|
+
/** The registry of the caller: `clientModule` and the modules, with their required modules. The first use makes it. */
|
|
24
|
+
readonly registry: ProcedureRegistry;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* This function makes a caller of the procedures of the modules. The registry of the modules is
|
|
29
|
+
* made at the first call, so a caller costs nothing until it calls. The caller does not use the
|
|
30
|
+
* global registry.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* const gh = callerOf(ghModule);
|
|
34
|
+
* const runs = await gh<RunList>(["gh", "run", "list"], { repo: "owner/name" });
|
|
35
|
+
*/
|
|
36
|
+
export function callerOf(...modules: ModuleInput[]): Caller {
|
|
37
|
+
let registry: ProcedureRegistry | undefined;
|
|
38
|
+
const registryOf = (): ProcedureRegistry => (registry ??= createRegistry(...modules));
|
|
39
|
+
const call = async <T = unknown>(path: ProcedurePath, input: unknown, options: CallerOptions = {}): Promise<T> => {
|
|
40
|
+
const output = await invokePath<T>(path, input, { ...options, registry: registryOf() });
|
|
41
|
+
return outputValue(output, path);
|
|
42
|
+
};
|
|
43
|
+
Object.defineProperty(call, "registry", { get: registryOf, enumerable: true });
|
|
44
|
+
return call as Caller;
|
|
45
|
+
}
|
|
@@ -21,6 +21,8 @@ export {
|
|
|
21
21
|
getAllOutputSchema,
|
|
22
22
|
entriesInputSchema,
|
|
23
23
|
entriesOutputSchema,
|
|
24
|
+
idsInputSchema,
|
|
25
|
+
idsOutputSchema,
|
|
24
26
|
changesInputSchema,
|
|
25
27
|
changesOutputSchema,
|
|
26
28
|
sizeInputSchema,
|
|
@@ -44,6 +46,7 @@ export type {
|
|
|
44
46
|
GetAllInput,
|
|
45
47
|
EntriesInput,
|
|
46
48
|
CollectionEntry,
|
|
49
|
+
IdsInput,
|
|
47
50
|
ChangesInput,
|
|
48
51
|
ChangesOutput,
|
|
49
52
|
SizeInput,
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
getAllOutputSchema,
|
|
21
21
|
entriesInputSchema,
|
|
22
22
|
entriesOutputSchema,
|
|
23
|
+
idsInputSchema,
|
|
24
|
+
idsOutputSchema,
|
|
23
25
|
changesInputSchema,
|
|
24
26
|
changesOutputSchema,
|
|
25
27
|
sizeInputSchema,
|
|
@@ -39,6 +41,7 @@ import {
|
|
|
39
41
|
type HasInput,
|
|
40
42
|
type GetAllInput,
|
|
41
43
|
type EntriesInput,
|
|
44
|
+
type IdsInput,
|
|
42
45
|
type ChangesInput,
|
|
43
46
|
type ChangesOutput,
|
|
44
47
|
type SizeInput,
|
|
@@ -199,6 +202,25 @@ export function createCollectionProcedures(collectionName: string): AnyProcedure
|
|
|
199
202
|
},
|
|
200
203
|
}),
|
|
201
204
|
|
|
205
|
+
// Ids: the ids of the items without the values, for a full sync
|
|
206
|
+
defineProcedure({
|
|
207
|
+
path: [...basePath, "ids"],
|
|
208
|
+
input: idsInputSchema,
|
|
209
|
+
output: idsOutputSchema,
|
|
210
|
+
metadata: {
|
|
211
|
+
effects: { reads: [resource], writes: [] },
|
|
212
|
+
description: `Get the ids of the items of ${collectionName}, without the values`,
|
|
213
|
+
tags: ["collections", collectionName, "read"],
|
|
214
|
+
},
|
|
215
|
+
handler: async (_input: IdsInput, ctx: ProcedureContext) => {
|
|
216
|
+
const storage = getStorage(ctx);
|
|
217
|
+
if (storage.getIds) return storage.getIds();
|
|
218
|
+
// A storage without getIds() gives its entries: the values stay on the server
|
|
219
|
+
if (storage.getEntries) return (await storage.getEntries()).map(([id]) => id);
|
|
220
|
+
throw new Error(`The storage of ${collectionName} cannot give the ids of its items`);
|
|
221
|
+
},
|
|
222
|
+
}),
|
|
223
|
+
|
|
202
224
|
// Changes: the items that changed after a version, for a delta sync (roadmap R28)
|
|
203
225
|
defineProcedure({
|
|
204
226
|
path: [...basePath, "changes"],
|
|
@@ -406,6 +406,43 @@ export const entriesOutputSchema : ZodLike<CollectionEntry[]> = createSchema<Col
|
|
|
406
406
|
};
|
|
407
407
|
});
|
|
408
408
|
|
|
409
|
+
// =============================================================================
|
|
410
|
+
// Ids Operation Schemas
|
|
411
|
+
// =============================================================================
|
|
412
|
+
|
|
413
|
+
/** Input for ids operation - empty object. */
|
|
414
|
+
export interface IdsInput {}
|
|
415
|
+
|
|
416
|
+
export const idsInputSchema : ZodLike<IdsInput> = createSchema<IdsInput>((data) => {
|
|
417
|
+
if (!isObject(data)) {
|
|
418
|
+
return {
|
|
419
|
+
success: false,
|
|
420
|
+
error: {
|
|
421
|
+
message: "Input must be an object",
|
|
422
|
+
errors: [{ path: [], message: "Input must be an object" }],
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
return { success: true, data: {} };
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Output for ids operation: the ids of the items, in the order of the entries, without the
|
|
431
|
+
* values. A full sync needs only the ids.
|
|
432
|
+
*/
|
|
433
|
+
export const idsOutputSchema : ZodLike<string[]> = createSchema<string[]>((data) => {
|
|
434
|
+
if (isArray(data) && data.every((id) => isString(id))) {
|
|
435
|
+
return { success: true, data: data as string[] };
|
|
436
|
+
}
|
|
437
|
+
return {
|
|
438
|
+
success: false,
|
|
439
|
+
error: {
|
|
440
|
+
message: "Output must be an array of ids",
|
|
441
|
+
errors: [{ path: [], message: "Output must be an array of ids" }],
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
});
|
|
445
|
+
|
|
409
446
|
// =============================================================================
|
|
410
447
|
// Changes Operation Schemas
|
|
411
448
|
// =============================================================================
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* The contract (the suite of `@procedures/collections/testing` tests it for each storage):
|
|
14
14
|
* - A storage keeps a copy of each value that a write takes, and each read gives a new copy.
|
|
15
15
|
* - A stored `null` reads back as `null`. A missing id reads back as `undefined`.
|
|
16
|
+
* - `getIds()` gives the ids of `getEntries()`, in the same order.
|
|
16
17
|
* - A delta of `getChangesSince` follows the order of the last change of each id.
|
|
17
18
|
* - After `close()`, each operation rejects with an error whose message says "closed". A second
|
|
18
19
|
* `close()` does nothing.
|
|
@@ -27,6 +28,11 @@ export interface CollectionStorage<T> {
|
|
|
27
28
|
* them does not have this method.
|
|
28
29
|
*/
|
|
29
30
|
getEntries?(): Promise<Array<[string, T]>>;
|
|
31
|
+
/**
|
|
32
|
+
* The ids of the items, in the order of the entries, without the values. A storage that cannot
|
|
33
|
+
* give the ids cheaply does not have this method.
|
|
34
|
+
*/
|
|
35
|
+
getIds?(): Promise<string[]>;
|
|
30
36
|
/**
|
|
31
37
|
* The changes after a version (roadmap R28). A storage without a change log does not have
|
|
32
38
|
* this method, and a sync uses `getEntries()`.
|
package/src/procedures/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ export type {
|
|
|
25
25
|
RegistrationOptions,
|
|
26
26
|
ProcedureRegistryLike,
|
|
27
27
|
Effects,
|
|
28
|
+
FsRootRules,
|
|
28
29
|
PermittedEffects,
|
|
29
30
|
Register,
|
|
30
31
|
ClearanceOf,
|
|
@@ -107,6 +108,9 @@ export {
|
|
|
107
108
|
registerClientProcedures,
|
|
108
109
|
} from "./module.js";
|
|
109
110
|
export type { ModuleInput } from "./module.js";
|
|
111
|
+
// One call for a registry of some modules, `invokePath` and `outputValue`
|
|
112
|
+
export { callerOf } from "./caller.js";
|
|
113
|
+
export type { Caller, CallerOptions } from "./caller.js";
|
|
110
114
|
|
|
111
115
|
// Procedure reference system (procedure-as-data)
|
|
112
116
|
export {
|
package/src/procedures/invoke.ts
CHANGED
|
@@ -486,6 +486,8 @@ export function createProcedureContext(
|
|
|
486
486
|
if (options.expose) context.expose = options.expose;
|
|
487
487
|
if (options.repository) context.repository = options.repository;
|
|
488
488
|
if (options.onRef) context.onRef = options.onRef;
|
|
489
|
+
// The procedure of the parent invocation made this call (roadmap R33: the root of the files)
|
|
490
|
+
if (invocation?.parent) context.caller = invocation.parent.procedure;
|
|
489
491
|
// Every context has a bus. With a signal, the waits of the handler on the bus end when the
|
|
490
492
|
// invocation aborts, so a stream handler that waits on ctx.bus.stream() can finish. (Before,
|
|
491
493
|
// no host set ctx.bus: deep dive CORE-15.)
|
package/src/procedures/types.ts
CHANGED
|
@@ -83,10 +83,56 @@ export interface ProcedureMetadata {
|
|
|
83
83
|
*/
|
|
84
84
|
stream?: { readonly unbounded?: boolean } | undefined;
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The rules of the procedure under a root of the files (roadmap R33): the inputs that name a
|
|
88
|
+
* path, or the reason that a root cannot confine it. A host with a root (`--fs-root`,
|
|
89
|
+
* `MCP_FS_ROOT`) reads them. Without the rules, the host decides from `effects`.
|
|
90
|
+
*/
|
|
91
|
+
fsRoot?: FsRootRules | undefined;
|
|
92
|
+
|
|
86
93
|
/** Custom metadata fields */
|
|
87
94
|
[key: string]: unknown;
|
|
88
95
|
}
|
|
89
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The rules of a procedure under a root of the files (roadmap R33). A host with a root keeps each
|
|
99
|
+
* path of these inputs in the root, or it refuses the procedure. `confineToRoot()` of
|
|
100
|
+
* `@procedures/fs` applies the rules. Without a root, the rules change nothing.
|
|
101
|
+
*/
|
|
102
|
+
export interface FsRootRules {
|
|
103
|
+
/**
|
|
104
|
+
* The input that names the working folder. When the call gives no value, the procedure gets the
|
|
105
|
+
* root. A relative value starts at the root.
|
|
106
|
+
*/
|
|
107
|
+
readonly cwd?: string;
|
|
108
|
+
/**
|
|
109
|
+
* The inputs that name a file or a folder: a text, or a list of texts. A relative path starts at
|
|
110
|
+
* the root. The procedure gets the absolute path.
|
|
111
|
+
*/
|
|
112
|
+
readonly paths?: readonly string[];
|
|
113
|
+
/**
|
|
114
|
+
* The inputs that name a file or a folder relative to the working folder, for example the files
|
|
115
|
+
* of an archive. A relative name starts at the working folder, or at the root without `cwd`.
|
|
116
|
+
* The host examines each path, and it does not change the value.
|
|
117
|
+
*/
|
|
118
|
+
readonly names?: readonly string[];
|
|
119
|
+
/**
|
|
120
|
+
* The inputs of `paths` whose default is outside the working folder. Under a root, a call
|
|
121
|
+
* without a value of such an input is refused.
|
|
122
|
+
*/
|
|
123
|
+
readonly required?: readonly string[];
|
|
124
|
+
/** The values of the inputs that are not paths, for example `:memory:` of SQLite. */
|
|
125
|
+
readonly values?: readonly string[];
|
|
126
|
+
/** The reason that a root cannot confine the procedure. Under a root, the host refuses it. */
|
|
127
|
+
readonly refuse?: string;
|
|
128
|
+
/**
|
|
129
|
+
* True for a procedure that starts the program of its input (`shell.run`). Under a root, the
|
|
130
|
+
* host refuses a call of a client. It accepts the nested call of a confined procedure that
|
|
131
|
+
* names this procedure in `metadata.calls`.
|
|
132
|
+
*/
|
|
133
|
+
readonly program?: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
90
136
|
// =============================================================================
|
|
91
137
|
// Capabilities (CAPABILITIES-2026-10)
|
|
92
138
|
// =============================================================================
|
|
@@ -294,6 +340,13 @@ export interface ProcedureContext {
|
|
|
294
340
|
* database for a lower clearance, for example. Empty when the host gives none.
|
|
295
341
|
*/
|
|
296
342
|
resources: ResourcesOf;
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The procedure whose handler made this call through its `ctx.client`. A call of a host or a
|
|
346
|
+
* client has no caller. The framework sets it from the invocation, so an input cannot change
|
|
347
|
+
* it. The root of the files reads it (roadmap R33).
|
|
348
|
+
*/
|
|
349
|
+
caller?: AnyProcedure | undefined;
|
|
297
350
|
}
|
|
298
351
|
|
|
299
352
|
/**
|
package/src/protocol.ts
CHANGED
|
@@ -47,7 +47,8 @@ export type ProtocolErrorCode =
|
|
|
47
47
|
| "UNAVAILABLE"
|
|
48
48
|
| "CONNECTION_CLOSED"
|
|
49
49
|
| "CONNECTION_NOT_FOUND"
|
|
50
|
-
| "TOPIC_NOT_FOUND"
|
|
50
|
+
| "TOPIC_NOT_FOUND"
|
|
51
|
+
| "TOPIC_CLEARED";
|
|
51
52
|
|
|
52
53
|
/**
|
|
53
54
|
* The error codes of the protocol, on each face (HTTP, WebSocket, MCP, local). An error that a
|
|
@@ -78,6 +79,7 @@ export const ERROR_CODES: Readonly<Record<ProtocolErrorCode, ErrorCodeInfo>> = {
|
|
|
78
79
|
CONNECTION_CLOSED: { http: 503, retryable: true, meaning: "The connection closed before the call left" },
|
|
79
80
|
CONNECTION_NOT_FOUND: { http: 404, retryable: false, meaning: "The server has no connection with this id" },
|
|
80
81
|
TOPIC_NOT_FOUND: { http: 404, retryable: false, meaning: "The topic rule of the host has no topic with this name" },
|
|
82
|
+
TOPIC_CLEARED: { http: 410, retryable: false, meaning: "The host cleared a topic of the subscription: clear() of its bus removed the listener" },
|
|
81
83
|
};
|
|
82
84
|
|
|
83
85
|
/** True when a code is an error code of the protocol. */
|
|
@@ -24,6 +24,7 @@ import { IdempotencyCache, idempotencyKeyOf, isRepeatable, type IdempotencyOptio
|
|
|
24
24
|
import { optionalMs, type Duration } from "../duration.js";
|
|
25
25
|
import type { CollectionStorage } from "../procedures/collection/storage.js";
|
|
26
26
|
import { TIMER_DEFAULTS } from "../timers.js";
|
|
27
|
+
import { realClock, type Clock } from "../clock.js";
|
|
27
28
|
import { errorCodeOf } from "../protocol.js";
|
|
28
29
|
import { EventPublisher, type EventPublisherOptions } from "../events/publisher.js";
|
|
29
30
|
import { getGlobalEventBus } from "../events/bus.js";
|
|
@@ -49,8 +50,18 @@ export interface UseStorageOptions {
|
|
|
49
50
|
* flush fails, the old storage stays and `useStorage` throws.
|
|
50
51
|
*/
|
|
51
52
|
drain?: boolean | undefined;
|
|
52
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* True: the server closes the old storage after the change. The close waits for the calls that
|
|
55
|
+
* took the old storage, for the time of `grace` at most.
|
|
56
|
+
*/
|
|
53
57
|
close?: boolean | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* The longest time that the close waits for the calls on the old storage, for example
|
|
60
|
+
* `seconds(5)`. When the time ends, the server closes the old storage. A call that still uses it
|
|
61
|
+
* then fails with the error of a closed storage. 0: the server closes it at once.
|
|
62
|
+
* @default 30 seconds (`TIMER_DEFAULTS.server.storageGraceMs`)
|
|
63
|
+
*/
|
|
64
|
+
grace?: Duration | undefined;
|
|
54
65
|
}
|
|
55
66
|
|
|
56
67
|
/**
|
|
@@ -66,6 +77,12 @@ export interface ProcedureServerOptions extends ServerOptions {
|
|
|
66
77
|
/** Collections to configure with storage backends */
|
|
67
78
|
collections?: Record<string, CollectionStorage<unknown>>;
|
|
68
79
|
|
|
80
|
+
/**
|
|
81
|
+
* The clock of the wait of `useStorage` for the calls on an old storage (roadmap R35). The
|
|
82
|
+
* default is the clock of the platform. A test gives a fake clock.
|
|
83
|
+
*/
|
|
84
|
+
clock?: Clock | undefined;
|
|
85
|
+
|
|
69
86
|
/**
|
|
70
87
|
* The procedures that the callers of this server can reach. `registerFromRegistry()` registers
|
|
71
88
|
* only these procedures. A data-driven procedure (control flow, `eval`, a procedure that
|
|
@@ -143,13 +160,16 @@ export function isUnboundedStream(response: { metadata?: Record<string, unknown>
|
|
|
143
160
|
}
|
|
144
161
|
|
|
145
162
|
/**
|
|
146
|
-
* True when the presented token is the expected one. The comparison
|
|
147
|
-
*
|
|
163
|
+
* True when the presented token is the expected one. The comparison reads each character of the
|
|
164
|
+
* expected token for each presented token, so its time depends only on the expected token. The
|
|
165
|
+
* time tells neither the token nor its length. The function runs in a browser too, so it uses
|
|
166
|
+
* no `node:crypto`.
|
|
148
167
|
*/
|
|
149
168
|
export function tokenMatches(expected: string, presented: string | undefined): boolean {
|
|
150
|
-
if (presented === undefined
|
|
151
|
-
|
|
152
|
-
|
|
169
|
+
if (presented === undefined) return false;
|
|
170
|
+
// A difference of the lengths makes the result false, after the same loop as an equal length
|
|
171
|
+
let difference = expected.length ^ presented.length;
|
|
172
|
+
for (let index = 0; index < expected.length; index++) difference |= expected.charCodeAt(index) ^ (presented.charCodeAt(index) | 0);
|
|
153
173
|
return difference === 0;
|
|
154
174
|
}
|
|
155
175
|
|
|
@@ -163,6 +183,15 @@ export interface StopOptions {
|
|
|
163
183
|
grace?: Duration | undefined;
|
|
164
184
|
}
|
|
165
185
|
|
|
186
|
+
/** The items of a stream. At the end of the stream, or when its reader stops, `release` runs. */
|
|
187
|
+
async function* releasedAtEnd<T>(items: AsyncIterable<T>, release: () => void): AsyncGenerator<T, void, undefined> {
|
|
188
|
+
try {
|
|
189
|
+
yield* items;
|
|
190
|
+
} finally {
|
|
191
|
+
release();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
166
195
|
/** A signal that aborts when one of the signals aborts. */
|
|
167
196
|
function joinSignals(first: AbortSignal | undefined, second: AbortSignal): AbortSignal {
|
|
168
197
|
if (!first) return second;
|
|
@@ -268,6 +297,10 @@ export function errorResponse(id: string, error: unknown): ServerResponse<unknow
|
|
|
268
297
|
export class ProcedureServer extends Server implements RepositoryProvider {
|
|
269
298
|
private readonly procedureRegistry: ProcedureRegistry;
|
|
270
299
|
private readonly storages = new Map<string, CollectionStorage<unknown>>();
|
|
300
|
+
/** For each storage: the calls in flight that took it, and the closes that wait for them */
|
|
301
|
+
private readonly storageUses = new Map<CollectionStorage<unknown>, { calls: number; waiters: Array<() => void> }>();
|
|
302
|
+
/** The clock of the wait of `useStorage` */
|
|
303
|
+
private readonly clock: Clock;
|
|
271
304
|
/** The procedures that `registerProcedure` pinned, by key */
|
|
272
305
|
private readonly pinnedProcedures = new Map<string, AnyProcedure>();
|
|
273
306
|
/**
|
|
@@ -305,6 +338,7 @@ export class ProcedureServer extends Server implements RepositoryProvider {
|
|
|
305
338
|
super(options);
|
|
306
339
|
|
|
307
340
|
this.procedureRegistry = options.registry ?? PROCEDURE_REGISTRY;
|
|
341
|
+
this.clock = options.clock ?? realClock;
|
|
308
342
|
this.expose = options.expose;
|
|
309
343
|
this.strictCalls = options.strictCalls ?? false;
|
|
310
344
|
this.interceptors = options.interceptors ?? [];
|
|
@@ -376,7 +410,11 @@ export class ProcedureServer extends Server implements RepositoryProvider {
|
|
|
376
410
|
* failed flush keeps the old storage and throws. A call that arrives during the flush goes
|
|
377
411
|
* to the old storage.
|
|
378
412
|
* - `close`: the server closes the old storage after the change. The close of a
|
|
379
|
-
* `HybridStorage` sends the writes that arrived during the flush.
|
|
413
|
+
* `HybridStorage` sends the writes that arrived during the flush. An old storage that another
|
|
414
|
+
* collection still uses stays open: the server closes it when the last collection leaves it.
|
|
415
|
+
* - The new calls use the new storage at once. Before the close, the server waits for the calls
|
|
416
|
+
* that took the old storage, for the time of `grace` at most. Before, such a call could fail
|
|
417
|
+
* because the storage was closed.
|
|
380
418
|
*
|
|
381
419
|
* @param name - Collection name
|
|
382
420
|
* @param storage - Storage backend
|
|
@@ -393,20 +431,76 @@ export class ProcedureServer extends Server implements RepositoryProvider {
|
|
|
393
431
|
return this.replaceStorage(name, storage as CollectionStorage<unknown>, options);
|
|
394
432
|
}
|
|
395
433
|
|
|
396
|
-
/**
|
|
434
|
+
/**
|
|
435
|
+
* The change of `useStorage` with options: flush the old storage, change, wait for the calls on
|
|
436
|
+
* the old storage, and close it.
|
|
437
|
+
*/
|
|
397
438
|
private async replaceStorage(name: string, storage: CollectionStorage<unknown>, options: UseStorageOptions): Promise<this> {
|
|
439
|
+
const graceMs = optionalMs(options.grace, "grace") ?? TIMER_DEFAULTS.server.storageGraceMs;
|
|
398
440
|
const old = this.storages.get(name);
|
|
399
441
|
const other = old !== undefined && old !== storage ? old : undefined;
|
|
400
442
|
if (other && options.drain) {
|
|
401
443
|
await other.flush?.();
|
|
402
444
|
}
|
|
403
445
|
this.storages.set(name, storage);
|
|
404
|
-
|
|
446
|
+
// Another collection that uses the same storage keeps it open. Before, the close broke the
|
|
447
|
+
// calls of that collection (tracker O31).
|
|
448
|
+
if (other && options.close && ![...this.storages.values()].includes(other)) {
|
|
449
|
+
await this.callsOnStorageEnded(other, graceMs);
|
|
405
450
|
await other.close();
|
|
406
451
|
}
|
|
407
452
|
return this;
|
|
408
453
|
}
|
|
409
454
|
|
|
455
|
+
/** A promise that resolves when no call uses the storage, or when `graceMs` ends. */
|
|
456
|
+
private callsOnStorageEnded(storage: CollectionStorage<unknown>, graceMs: number): Promise<void> {
|
|
457
|
+
const use = this.storageUses.get(storage);
|
|
458
|
+
if (!use || graceMs <= 0) return Promise.resolve();
|
|
459
|
+
return new Promise<void>((resolve) => {
|
|
460
|
+
const done = (): void => {
|
|
461
|
+
cancel();
|
|
462
|
+
const index = use.waiters.indexOf(done);
|
|
463
|
+
if (index >= 0) use.waiters.splice(index, 1);
|
|
464
|
+
resolve();
|
|
465
|
+
};
|
|
466
|
+
const cancel = this.clock.after(graceMs, done);
|
|
467
|
+
use.waiters.push(done);
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* The repository of one call. The call is a user of each storage that it takes, until
|
|
473
|
+
* `release()`. Then a close of `useStorage` can go on.
|
|
474
|
+
*/
|
|
475
|
+
private callRepository(): { repository: RepositoryProvider; release: () => void } {
|
|
476
|
+
const taken = new Set<CollectionStorage<unknown>>();
|
|
477
|
+
let released = false;
|
|
478
|
+
const repository: RepositoryProvider = {
|
|
479
|
+
getStorage: <T>(collection: string): CollectionStorage<T> => {
|
|
480
|
+
const storage = this.getStorage<unknown>(collection);
|
|
481
|
+
if (!released && !taken.has(storage)) {
|
|
482
|
+
taken.add(storage);
|
|
483
|
+
const use = this.storageUses.get(storage) ?? { calls: 0, waiters: [] };
|
|
484
|
+
use.calls++;
|
|
485
|
+
this.storageUses.set(storage, use);
|
|
486
|
+
}
|
|
487
|
+
return storage as CollectionStorage<T>;
|
|
488
|
+
},
|
|
489
|
+
hasCollection: (collection: string): boolean => this.hasCollection(collection),
|
|
490
|
+
};
|
|
491
|
+
const release = (): void => {
|
|
492
|
+
if (released) return;
|
|
493
|
+
released = true;
|
|
494
|
+
for (const storage of taken) {
|
|
495
|
+
const use = this.storageUses.get(storage);
|
|
496
|
+
if (!use || --use.calls > 0) continue;
|
|
497
|
+
this.storageUses.delete(storage);
|
|
498
|
+
for (const waiter of use.waiters.slice()) waiter();
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
return { repository, release };
|
|
502
|
+
}
|
|
503
|
+
|
|
410
504
|
/**
|
|
411
505
|
* Configure multiple storages at once.
|
|
412
506
|
*
|
|
@@ -642,6 +736,9 @@ export class ProcedureServer extends Server implements RepositoryProvider {
|
|
|
642
736
|
|
|
643
737
|
/** Run a procedure for a request with a signal. */
|
|
644
738
|
private async run(procedure: AnyProcedure, request: ServerRequest<unknown>, signal: AbortSignal | undefined): Promise<ServerResponse<unknown>> {
|
|
739
|
+
// The call uses the storages that it takes until its end: the end of a stream, for a stream
|
|
740
|
+
const { repository, release } = this.callRepository();
|
|
741
|
+
let streams = false;
|
|
645
742
|
try {
|
|
646
743
|
// One invocation path for every host (ARCHITECTURE-PROPOSALS P1): input validation, the
|
|
647
744
|
// context, the expose rule for nested calls (BUGS-2026-07 H18) and output validation
|
|
@@ -650,7 +747,7 @@ export class ProcedureServer extends Server implements RepositoryProvider {
|
|
|
650
747
|
registry: this.procedureRegistry,
|
|
651
748
|
metadata: request.metadata,
|
|
652
749
|
signal,
|
|
653
|
-
repository
|
|
750
|
+
repository,
|
|
654
751
|
expose: this.expose,
|
|
655
752
|
strictCalls: this.strictCalls,
|
|
656
753
|
interceptors: this.interceptors,
|
|
@@ -666,16 +763,19 @@ export class ProcedureServer extends Server implements RepositoryProvider {
|
|
|
666
763
|
if (output.kind === "stream") {
|
|
667
764
|
// The faces that collect a stream read this mark (roadmap R15)
|
|
668
765
|
const unbounded = (procedure.metadata as { stream?: { unbounded?: boolean } } | undefined)?.stream?.unbounded === true;
|
|
766
|
+
streams = true;
|
|
669
767
|
return {
|
|
670
768
|
id: request.id,
|
|
671
769
|
status: { type: "success", code: 200 },
|
|
672
|
-
stream: output.items,
|
|
770
|
+
stream: releasedAtEnd(output.items, release),
|
|
673
771
|
metadata: unbounded ? { [STREAM_METADATA_KEY]: "unbounded" } : {},
|
|
674
772
|
};
|
|
675
773
|
}
|
|
676
774
|
return { id: request.id, status: { type: "success", code: 200 }, payload: output.value, metadata: {} };
|
|
677
775
|
} catch (error) {
|
|
678
776
|
return errorResponse(request.id, error);
|
|
777
|
+
} finally {
|
|
778
|
+
if (!streams) release();
|
|
679
779
|
}
|
|
680
780
|
}
|
|
681
781
|
|
package/src/timers.ts
CHANGED
|
@@ -47,6 +47,10 @@ export interface TimerDefaults {
|
|
|
47
47
|
/** The time until the first frame of a call of the server to a client. */
|
|
48
48
|
readonly reverseRequestTimeoutMs: number;
|
|
49
49
|
};
|
|
50
|
+
readonly httpServer: {
|
|
51
|
+
/** The quiet time of an NDJSON stream. After it, the server writes a keep-alive line. */
|
|
52
|
+
readonly keepAliveMs: number;
|
|
53
|
+
};
|
|
50
54
|
readonly idempotency: {
|
|
51
55
|
/** The time that the server keeps the result of a keyed write. */
|
|
52
56
|
readonly ttlMs: number;
|
|
@@ -54,6 +58,11 @@ export interface TimerDefaults {
|
|
|
54
58
|
readonly server: {
|
|
55
59
|
/** The time that `ProcedureServer.stop()` waits for the calls that run. */
|
|
56
60
|
readonly stopGraceMs: number;
|
|
61
|
+
/**
|
|
62
|
+
* The longest time that `ProcedureServer.useStorage` with `close` waits for the calls on the
|
|
63
|
+
* old storage, before it closes the old storage.
|
|
64
|
+
*/
|
|
65
|
+
readonly storageGraceMs: number;
|
|
57
66
|
};
|
|
58
67
|
}
|
|
59
68
|
|
|
@@ -77,6 +86,11 @@ export const TIMER_DEFAULTS: TimerDefaults = Object.freeze({
|
|
|
77
86
|
// 15 s: with 2 misses, the server finds a dead client after 30 s, not later than a call of the
|
|
78
87
|
// server to it reaches its request time of 30 s. Before, 30 s gave 60 s (the timer law, R34).
|
|
79
88
|
webSocketServer: Object.freeze({ heartbeatIntervalMs: 15_000, heartbeatMisses: 2, reverseRequestTimeoutMs: 30_000 }),
|
|
89
|
+
// 15 s: less than the idle time of 60 s that many proxies and load balancers use. A quiet NDJSON
|
|
90
|
+
// stream then stays open (WIRE-PROTOCOL.md, "The keep-alive of an NDJSON stream").
|
|
91
|
+
httpServer: Object.freeze({ keepAliveMs: 15_000 }),
|
|
80
92
|
idempotency: Object.freeze({ ttlMs: 10 * 60_000 }),
|
|
81
|
-
|
|
93
|
+
// 30 s: the request time of the WebSocket client. A call that runs longer timed out at its
|
|
94
|
+
// client already.
|
|
95
|
+
server: Object.freeze({ stopGraceMs: 0, storageGraceMs: 30_000 }),
|
|
82
96
|
});
|