@acid-cat/core-ts 0.3.0 → 0.4.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/README.md CHANGED
@@ -35,9 +35,10 @@ export const aircon = defineApp<{ unit: Unit }>({
35
35
 
36
36
  - `run` gets the input, an `AbortSignal` and the context. It returns `ok(value)` or `fail(tag, data)`. If it throws, the call fails as `internal`.
37
37
  - A failure schema describes the whole failure object, with its `_tag`.
38
- - `availability` is an optional function that returns an observable availability.
38
+ - `availability` is an optional function that returns an observable availability. A session reads it with `getSnapshot` when a call comes in. It subscribes to it only while `onChange` or the handle has listeners.
39
39
  - `state` is an optional `{ schema, source }`: the schema of the [state](../../spec/invocation.md#state), and a function that returns an observable. The handle gives it as `snapshot`.
40
40
  - `capabilitiesFor<Context>()` sets the context type one time. The other types come from the schemas.
41
+ - `defineCapability` and `capabilitiesFor` keep the literal type of `id`, so that an application can type its ids. `run`, `availability` and `state` all get the same `Context`.
41
42
 
42
43
  ## Write the catalog
43
44
 
@@ -60,6 +61,20 @@ Standard Schema does not convert to JSON Schema. Give the converter of your sche
60
61
  | `cliMain(app, options)`, `runCli(…)` | [CLI](../../spec/bindings/cli.md) | The command line of the process, or an exit code |
61
62
  | `installWebMcp(session)` | [WebMCP](../../spec/bindings/webmcp.md) | Tools in the page, with a status |
62
63
 
64
+ When the signal of a request aborts, `mcpHandler` and `httpHandler` cancel the call of the request.
65
+
66
+ ### Typed handles
67
+
68
+ `getHandle(session.handles, capability)` gives the handle of a capability with the types of its schemas. For a capability outside the view, the handle is always unavailable, and each call rejects as `unknown-capability`.
69
+
70
+ ```ts
71
+ import { createSession, getHandle } from "@acid-cat/core-ts";
72
+
73
+ const session = createSession(aircon, { target: "app", context: { unit }, toJsonSchema });
74
+ const power = getHandle(session.handles, powerSet); // HandleOf<typeof powerSet>
75
+ const result = await power.execute({ on: true }); // Result<null, { _tag: "Offline"; since?: string }>
76
+ ```
77
+
63
78
  ### Context for each request
64
79
 
65
80
  `mcpHandler` and `httpHandler` take `context` or `createContext`. With `context`, all requests share one context. With `createContext(request)`, the handler makes a new context for each request that passes the screening. Use it when a request must not use objects that an earlier request made, for example on Cloudflare Workers, or when a stateful service must not be shared between callers. When the context has `Symbol.asyncDispose` or `Symbol.dispose`, the handler calls it after the response:
@@ -84,6 +99,26 @@ const handler = httpHandler(aircon, {
84
99
  });
85
100
  ```
86
101
 
102
+ ### Commands of the application
103
+
104
+ The option `commands` of `cliMain` and `runCli` adds commands of the application beside the commands of the capabilities, `call` and `list`. A command has a `name`, a `description` for the help, and `run(args, io)`. `run` gets the arguments after the name of the command, and `io` with `stdout`, `stderr`, `signal` and the `context`. It returns the exit code. The name must not be `call`, `list`, the name of another command of the application, or the first word of the command of a capability. If it is, `runCli` throws. The option `description` gives the text at the top of the help. The default is the description of the application, then its title, then the name of the program.
105
+
106
+ ```ts
107
+ import { cliMain } from "@acid-cat/core-ts";
108
+ import type { CliCommand } from "@acid-cat/core-ts";
109
+
110
+ const report: CliCommand<{ unit: Unit }> = {
111
+ name: "report",
112
+ description: "Write the temperature of the room.",
113
+ run: (_args, { context, stdout }) => {
114
+ stdout(`The room is at ${context.unit.state.roomCelsius} °C.`);
115
+ return 0;
116
+ },
117
+ };
118
+
119
+ cliMain(aircon, { name: "aircon", version: "0.1.0", context: { unit }, toJsonSchema, description: "Control the air conditioner.", commands: [report] });
120
+ ```
121
+
87
122
  ## Examples and tests
88
123
 
89
124
  - [`examples/aircon.ts`](examples/aircon.ts) is the air conditioner of [`examples/aircon.pkl`](../../examples/aircon.pkl), with a simulated unit. Its catalog agrees with the Pkl catalog.
package/dist/app.d.ts CHANGED
@@ -7,8 +7,12 @@ export interface Observable<T> {
7
7
  unsubscribe(): void;
8
8
  };
9
9
  }
10
- type Output<S> = S extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<S> : null;
11
- type FailureOf<F> = {
10
+ /** The input that `run` receives: the output of the input schema, or an empty object. */
11
+ export type InputOf<S> = S extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<S> : Record<string, never>;
12
+ /** The value of a successful call: the output of the output schema, or `null`. */
13
+ export type Output<S> = S extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<S> : null;
14
+ /** The failures, each with its `_tag`. */
15
+ export type FailureOf<F> = {
12
16
  [Tag in keyof F]: {
13
17
  readonly _tag: Tag;
14
18
  } & (F[Tag] extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<F[Tag]> : object);
@@ -17,13 +21,16 @@ export interface Capability<Context = unknown, Input extends StandardSchemaV1 |
17
21
  readonly [tag: string]: StandardSchemaV1;
18
22
  } = {
19
23
  readonly [tag: string]: StandardSchemaV1;
20
- }> {
21
- readonly id: string;
24
+ }, StateSchema extends StandardSchemaV1 = StandardSchemaV1, Id extends string = string> {
25
+ readonly id: Id;
22
26
  readonly description: string;
23
27
  /** The targets that can use the capability. With no audience, no target can use it. */
24
28
  readonly audience: readonly string[];
25
29
  readonly readOnly?: boolean | undefined;
26
- /** The input: an object schema. With no input schema, the input is an empty object. */
30
+ /**
31
+ * The input: an object schema, or a union of object schemas. The catalog writes a union with `"type": "object"`. With
32
+ * no input schema, the input is an empty object.
33
+ */
27
34
  readonly input?: Input;
28
35
  /** The value of a successful call. With no output schema, the value is `null`. */
29
36
  readonly output?: OutputSchema;
@@ -31,21 +38,21 @@ export interface Capability<Context = unknown, Input extends StandardSchemaV1 |
31
38
  readonly failures?: Failures;
32
39
  /** A value that clients can observe (spec/invocation.md#state): its schema, and its source. */
33
40
  readonly state?: {
34
- readonly schema: StandardSchemaV1;
35
- readonly source: (context: Context) => Observable<unknown>;
41
+ readonly schema: StateSchema;
42
+ readonly source: (context: Context) => Observable<StandardSchemaV1.InferOutput<StateSchema>>;
36
43
  } | undefined;
37
44
  /** The availability of the capability. With none, the capability is always available. */
38
45
  readonly availability?: ((context: Context) => Observable<Availability>) | undefined;
39
- readonly run: (input: Input extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<Input> : Record<string, never>, call: {
46
+ readonly run: (input: InputOf<Input>, call: {
40
47
  readonly signal: AbortSignal;
41
48
  readonly context: Context;
42
49
  }) => Promise<Result<Output<OutputSchema>, FailureOf<Failures>>> | Result<Output<OutputSchema>, FailureOf<Failures>>;
43
50
  }
44
- export type AnyCapability<Context = any> = Capability<Context, any, any, any>;
45
- /** Declare a capability. `Context` is the type of the services that `run` receives. */
51
+ export type AnyCapability<Context = any> = Capability<Context, any, any, any, any>;
52
+ /** Declare a capability. `Context` is the type of the services that `run` receives. The id keeps its literal type. */
46
53
  export declare const defineCapability: <Context = unknown, Input extends StandardSchemaV1 | undefined = undefined, OutputSchema extends StandardSchemaV1 | undefined = undefined, Failures extends {
47
54
  readonly [tag: string]: StandardSchemaV1;
48
- } = {}>(capability: Capability<Context, Input, OutputSchema, Failures>) => Capability<Context, Input, OutputSchema, Failures>;
55
+ } = {}, StateSchema extends StandardSchemaV1 = never, const Id extends string = string>(capability: Capability<Context, Input, OutputSchema, Failures, StateSchema, Id>) => Capability<Context, Input, OutputSchema, Failures, StateSchema, Id>;
49
56
  /**
50
57
  * Declare capabilities for one context type. The other types come from the schemas:
51
58
  *
@@ -56,7 +63,7 @@ export declare const defineCapability: <Context = unknown, Input extends Standar
56
63
  */
57
64
  export declare const capabilitiesFor: <Context>() => <Input extends StandardSchemaV1 | undefined = undefined, OutputSchema extends StandardSchemaV1 | undefined = undefined, Failures extends {
58
65
  readonly [tag: string]: StandardSchemaV1;
59
- } = {}>(capability: Capability<Context, Input, OutputSchema, Failures>) => Capability<Context, Input, OutputSchema, Failures>;
66
+ } = {}, StateSchema extends StandardSchemaV1 = never, const Id extends string = string>(capability: Capability<Context, Input, OutputSchema, Failures, StateSchema, Id>) => Capability<Context, Input, OutputSchema, Failures, StateSchema, Id>;
60
67
  export interface Target {
61
68
  readonly bindings: readonly string[];
62
69
  readonly description?: string | undefined;
@@ -74,7 +81,10 @@ export interface App<Context = unknown> {
74
81
  export declare const defineApp: <Context = unknown>(app: App<Context>) => App<Context>;
75
82
  /** Converts a schema to JSON Schema 2020-12, for example `(schema) => z.toJSONSchema(schema)` with Zod. */
76
83
  export type ToJsonSchema = (schema: StandardSchemaV1) => JsonObject;
77
- /** The catalog of an application, as a JSON document. */
84
+ /**
85
+ * The catalog of an application, as a JSON document. The catalog is written one time for each application and
86
+ * `toJsonSchema`. Later calls with the same two give the same document.
87
+ */
78
88
  export declare function catalogOf(app: App<any>, options: {
79
89
  readonly toJsonSchema: ToJsonSchema;
80
90
  }): CatalogDocument;
@@ -82,4 +92,3 @@ export declare function catalogOf(app: App<any>, options: {
82
92
  export declare const renderCatalog: (app: App<any>, options: {
83
93
  readonly toJsonSchema: ToJsonSchema;
84
94
  }) => string;
85
- export {};
package/dist/app.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { canonicalText } from "@acid-cat/catalog";
2
+ import { objectInput } from "@acid-cat/spec-ts";
2
3
  //#region src/app.ts
3
4
  /**
4
5
  * Capabilities and applications in plain TypeScript. An application writes its own catalog.
5
6
  * See spec/catalog.md.
6
7
  */
7
- /** Declare a capability. `Context` is the type of the services that `run` receives. */
8
+ /** Declare a capability. `Context` is the type of the services that `run` receives. The id keeps its literal type. */
8
9
  const defineCapability = (capability) => capability;
9
10
  /**
10
11
  * Declare capabilities for one context type. The other types come from the schemas:
@@ -17,11 +18,28 @@ const defineCapability = (capability) => capability;
17
18
  const capabilitiesFor = () => (capability) => capability;
18
19
  /** Declare an application. */
19
20
  const defineApp = (app) => app;
20
- /** The catalog of an application, as a JSON document. */
21
+ const catalogs = /* @__PURE__ */ new WeakMap();
22
+ /**
23
+ * The catalog of an application, as a JSON document. The catalog is written one time for each application and
24
+ * `toJsonSchema`. Later calls with the same two give the same document.
25
+ */
21
26
  function catalogOf(app, options) {
27
+ let byConverter = catalogs.get(app);
28
+ if (byConverter === void 0) {
29
+ byConverter = /* @__PURE__ */ new WeakMap();
30
+ catalogs.set(app, byConverter);
31
+ }
32
+ let written = byConverter.get(options.toJsonSchema);
33
+ if (written === void 0) {
34
+ written = write(app, options.toJsonSchema);
35
+ byConverter.set(options.toJsonSchema, written);
36
+ }
37
+ return written;
38
+ }
39
+ function write(app, toJsonSchema) {
22
40
  const types = {};
23
41
  const toJson = (schema, where) => {
24
- const { $schema: _, $defs, ...json } = options.toJsonSchema(schema);
42
+ const { $schema: _, $defs, ...json } = toJsonSchema(schema);
25
43
  for (const [name, definition] of Object.entries($defs ?? {})) {
26
44
  const existing = types[name];
27
45
  if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(definition)) throw new Error(`${where}: two different schemas have the name ${name}. Give each one its own name.`);
@@ -36,7 +54,7 @@ function catalogOf(app, options) {
36
54
  capabilities[capability.id] = {
37
55
  description: capability.description,
38
56
  audience: capability.audience,
39
- ...capability.input === void 0 ? {} : { input: toJson(capability.input, capability.id) },
57
+ ...capability.input === void 0 ? {} : { input: objectInput(toJson(capability.input, capability.id), types) },
40
58
  ...capability.output === void 0 ? {} : { output: toJson(capability.output, capability.id) },
41
59
  ...Object.keys(failures).length === 0 ? {} : { failures },
42
60
  ...capability.readOnly === true ? { readOnly: true } : {},
package/dist/cli.d.ts CHANGED
@@ -6,12 +6,32 @@ export interface CliOptions<Context> {
6
6
  readonly toJsonSchema: ToJsonSchema;
7
7
  /** The target that the command line serves. The default is `"cli"`. */
8
8
  readonly target?: string | undefined;
9
+ /**
10
+ * The text at the top of the help. The default is the description of the application, then its title, then `name`.
11
+ */
12
+ readonly description?: string | undefined;
13
+ /**
14
+ * The commands of the application, beside the commands of the capabilities, `call` and `list`. The name of each one
15
+ * must not be `call`, `list`, the name of another command of the application, or the first word of the command of a
16
+ * capability (spec/bindings/cli.md#commands).
17
+ */
18
+ readonly commands?: readonly CliCommand<Context>[] | undefined;
9
19
  }
10
20
  export interface CliIo {
11
21
  readonly stdout: (text: string) => void;
12
22
  readonly stderr: (text: string) => void;
13
23
  readonly signal?: AbortSignal | undefined;
14
24
  }
25
+ /** A command that the application adds to the command line. */
26
+ export interface CliCommand<Context> {
27
+ readonly name: string;
28
+ /** The text that the help gives for the command. */
29
+ readonly description: string;
30
+ /** Run the command with the arguments that come after its name. The result is the exit code. */
31
+ run(args: readonly string[], io: CliIo & {
32
+ readonly context: Context;
33
+ }): number | Promise<number>;
34
+ }
15
35
  /** Run the command line as the main program of the process. An interrupt (`SIGINT`) cancels the call. */
16
36
  export declare function main<Context>(app: App<Context>, options: CliOptions<Context>): void;
17
37
  /** Run the command line with arguments. The result is the exit code (spec/bindings/cli.md#outcomes). */
package/dist/cli.js CHANGED
@@ -36,6 +36,16 @@ async function runCli(app, options, args, io) {
36
36
  path: commandPath(id)
37
37
  }));
38
38
  for (const { path } of commands) if (path[0] === "call" || path[0] === "list") throw new Error(`The command ${path.join(" ")} has the same name as a command of the command line.`);
39
+ const added = options.commands ?? [];
40
+ const taken = /* @__PURE__ */ new Set([
41
+ "call",
42
+ "list",
43
+ ...commands.map(({ path }) => path[0])
44
+ ]);
45
+ for (const command of added) {
46
+ if (taken.has(command.name)) throw new Error(`The command ${command.name} of the application has the same name as another command.`);
47
+ taken.add(command.name);
48
+ }
39
49
  const print = (outcome) => {
40
50
  if (isCallFailure(outcome)) io.stderr(JSON.stringify({
41
51
  kind: outcome.kind,
@@ -47,7 +57,8 @@ async function runCli(app, options, args, io) {
47
57
  try {
48
58
  const [first, ...rest] = args;
49
59
  if (first === void 0 || first === "--help" || first === "-h") {
50
- io.stdout(help(options.name, commands.map(({ path }) => path.join(" "))));
60
+ const description = options.description ?? app.description ?? app.title ?? options.name;
61
+ io.stdout(help(options.name, description, commands.map(({ path }) => path.join(" ")), added));
51
62
  return CLI_EXIT_CODES.value;
52
63
  }
53
64
  if (first === "--version" || first === "-v") {
@@ -80,6 +91,11 @@ async function runCli(app, options, args, io) {
80
91
  }
81
92
  return print(await session.invoke(id, input, { signal: io.signal }));
82
93
  }
94
+ const command = added.find(({ name }) => name === first);
95
+ if (command !== void 0) return await command.run(rest, {
96
+ ...io,
97
+ context: options.context
98
+ });
83
99
  const match = commands.filter(({ path }) => path.every((word, index) => args[index] === word)).sort((a, b) => b.path.length - a.path.length)[0];
84
100
  if (match === void 0) throw new UsageError(`There is no command ${args.filter((arg) => !arg.startsWith("-")).join(" ")}.`, CLI_EXIT_CODES["unknown-capability"]);
85
101
  const capability = session.view.capabilities[match.id];
@@ -142,14 +158,17 @@ function resolve(property, definitions) {
142
158
  const name = typeof property.$ref === "string" ? REFERENCE.exec(property.$ref)?.[1] : void 0;
143
159
  return name === void 0 ? property : definitions[name] ?? property;
144
160
  }
145
- function help(name, paths) {
161
+ function help(name, description, paths, added) {
146
162
  return [
163
+ description,
164
+ "",
147
165
  `Usage: ${name} <command> [flags]`,
148
166
  "",
149
167
  "Commands:",
150
168
  ...paths.map((path) => ` ${path}`),
151
169
  " call <id> [input]",
152
- " list"
170
+ " list",
171
+ ...added.map((command) => ` ${command.name} ${command.description}`)
153
172
  ].join("\n");
154
173
  }
155
174
  function usage(name, path, description, schema) {
package/dist/http.js CHANGED
@@ -16,6 +16,8 @@ function httpHandler(app, options) {
16
16
  maximumBytes
17
17
  });
18
18
  if (screened !== void 0) return rejection(screened);
19
+ const body = await readBounded(request, maximumBytes);
20
+ if ("rejection" in body) return rejection(body.rejection);
19
21
  const path = new URL(request.url).pathname.slice((options.base ?? "").length);
20
22
  return withContext(options, request, async (context) => {
21
23
  const session = createSession(app, {
@@ -31,8 +33,6 @@ function httpHandler(app, options) {
31
33
  if (request.method === "GET" && path === "/catalog" && options.serveCatalog !== false) return json(200, session.view);
32
34
  const id = request.method === "POST" && path.startsWith("/capabilities/") ? path.slice(14) : void 0;
33
35
  if (id === void 0) return json(404, callFailure("unknown-capability", "There is no such route."));
34
- const body = await readBounded(request, maximumBytes);
35
- if ("rejection" in body) return rejection(body.rejection);
36
36
  let input;
37
37
  try {
38
38
  input = body.text === "" ? {} : JSON.parse(body.text);
package/dist/index.d.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  export { capabilitiesFor, catalogOf, defineApp, defineCapability, renderCatalog } from "./app.js";
2
2
  export type { AnyCapability, App, Capability, Observable, Target, ToJsonSchema } from "./app.js";
3
- export { createSession } from "./session.js";
4
- export type { Handle, Session, SessionOptions } from "./session.js";
3
+ export { createSession, getHandle } from "./session.js";
4
+ export type { Handle, HandleOf, Session, SessionOptions } from "./session.js";
5
5
  export { mcpHandler } from "./mcp.js";
6
6
  export type { McpOptions, McpSettings } from "./mcp.js";
7
7
  export { httpHandler } from "./http.js";
8
8
  export type { HttpOptions, HttpSettings } from "./http.js";
9
9
  export type { ContextOption } from "./context.js";
10
10
  export { main as cliMain, runCli } from "./cli.js";
11
- export type { CliIo, CliOptions } from "./cli.js";
11
+ export type { CliCommand, CliIo, CliOptions } from "./cli.js";
12
12
  export { findHost, installWebMcp } from "./webmcp.js";
13
13
  export type { Installation, ModelContext, ModelContextTool, Phase, Status } from "./webmcp.js";
14
14
  export type { StandardSchemaV1 } from "./standard-schema.js";
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { capabilitiesFor, catalogOf, defineApp, defineCapability, renderCatalog } from "./app.js";
2
- import { createSession } from "./session.js";
2
+ import { createSession, getHandle } from "./session.js";
3
3
  import { mcpHandler } from "./mcp.js";
4
4
  import { httpHandler } from "./http.js";
5
5
  import { main, runCli } from "./cli.js";
6
6
  import { findHost, installWebMcp } from "./webmcp.js";
7
7
  import { AVAILABLE, CallFailureError, fail, ok, unavailable } from "@acid-cat/spec-ts";
8
- export { AVAILABLE, CallFailureError, capabilitiesFor, catalogOf, main as cliMain, createSession, defineApp, defineCapability, fail, findHost, httpHandler, installWebMcp, mcpHandler, ok, renderCatalog, runCli, unavailable };
8
+ export { AVAILABLE, CallFailureError, capabilitiesFor, catalogOf, main as cliMain, createSession, defineApp, defineCapability, fail, findHost, getHandle, httpHandler, installWebMcp, mcpHandler, ok, renderCatalog, runCli, unavailable };
package/dist/session.d.ts CHANGED
@@ -1,24 +1,39 @@
1
1
  import type { Availability, CatalogDocument, Outcome, Result } from "@acid-cat/spec-ts";
2
- import type { App, Observable, ToJsonSchema } from "./app.js";
3
- export interface Handle {
2
+ import type { AnyCapability, App, Capability, FailureOf, InputOf, Observable, Output, ToJsonSchema } from "./app.js";
3
+ import type { StandardSchemaV1 } from "./standard-schema.js";
4
+ /**
5
+ * The handle of a capability (spec/bindings/typescript.md#handles). `Input`, `Value`, `Failure` and `State` are the
6
+ * types of the schemas of the capability. `getHandle` gives a handle with these types.
7
+ */
8
+ export interface Handle<Input = unknown, Value = unknown, Failure = unknown, State = unknown> {
4
9
  readonly id: string;
5
10
  readonly description: string;
6
11
  readonly audience: readonly string[];
7
12
  readonly availability: Observable<Availability>;
8
13
  /** The state of the capability, if it has one. Each value is a copy. */
9
- readonly snapshot?: Observable<unknown>;
14
+ readonly snapshot?: Observable<State>;
10
15
  /** A call with an input that is already valid. A call failure rejects with a `CallFailureError`. */
11
- execute(input: unknown, options?: {
16
+ execute(input: Input, options?: {
12
17
  readonly signal?: AbortSignal | undefined;
13
- }): Promise<Result<unknown, unknown>>;
18
+ }): Promise<Result<Value, Failure>>;
14
19
  /** A call with an input that is not trusted. A call failure rejects with a `CallFailureError`. */
15
20
  invokeExternal(input: unknown, options?: {
16
21
  readonly signal?: AbortSignal | undefined;
17
22
  }): Promise<Result<unknown, unknown>>;
18
23
  }
24
+ /** The handle of a capability, with the types of its schemas. */
25
+ export type HandleOf<C> = C extends Capability<any, infer Input, infer OutputSchema, infer Failures, infer StateSchema> ? Handle<InputOf<Input>, Output<OutputSchema>, FailureOf<Failures>, StandardSchemaV1.InferOutput<StateSchema>> : never;
26
+ /**
27
+ * The handle of a capability in `handles`, with the types of the capability. For a capability outside the view, the
28
+ * handle is always unavailable, and each call rejects as `unknown-capability`.
29
+ */
30
+ export declare function getHandle<C extends AnyCapability>(handles: ReadonlyMap<string, Handle>, capability: C): HandleOf<C>;
19
31
  export interface Session {
20
32
  readonly target: string;
21
- /** The view of the target. Bindings take names, descriptions and schemas from it. */
33
+ /**
34
+ * The view of the target. Bindings take names, descriptions and schemas from it. The session writes the view when it
35
+ * is first read.
36
+ */
22
37
  readonly view: CatalogDocument;
23
38
  /** The handles of the capabilities in the view, by id. */
24
39
  readonly handles: ReadonlyMap<string, Handle>;
@@ -26,7 +41,10 @@ export interface Session {
26
41
  invoke(id: string, input: unknown, options?: {
27
42
  readonly signal?: AbortSignal | undefined;
28
43
  }): Promise<Outcome<unknown, unknown>>;
29
- /** An event each time the availability of a capability changes. */
44
+ /**
45
+ * An event each time the availability of a capability changes. The session subscribes to the availabilities when the
46
+ * first listener subscribes, and unsubscribes after the last listener unsubscribes.
47
+ */
30
48
  onChange(listener: () => void): {
31
49
  unsubscribe(): void;
32
50
  };
package/dist/session.js CHANGED
@@ -4,23 +4,48 @@ import { AVAILABLE, CallFailureError, callFailure, isCallFailure, viewOf } from
4
4
  /**
5
5
  * Sessions and handles (spec/invocation.md, spec/bindings/typescript.md).
6
6
  */
7
+ /**
8
+ * The handle of a capability in `handles`, with the types of the capability. For a capability outside the view, the
9
+ * handle is always unavailable, and each call rejects as `unknown-capability`.
10
+ */
11
+ function getHandle(handles, capability) {
12
+ return handles.get(capability.id) ?? outsideView(capability);
13
+ }
14
+ function outsideView(capability) {
15
+ const failure = callFailure("unknown-capability", `There is no capability ${capability.id}.`);
16
+ const unavailable = {
17
+ available: false,
18
+ reason: failure.message
19
+ };
20
+ const reject = () => Promise.reject(new CallFailureError(failure));
21
+ return {
22
+ id: capability.id,
23
+ description: capability.description,
24
+ audience: [],
25
+ availability: {
26
+ getSnapshot: () => unavailable,
27
+ subscribe: () => ({ unsubscribe() {} })
28
+ },
29
+ execute: reject,
30
+ invokeExternal: reject
31
+ };
32
+ }
7
33
  const always = {
8
34
  getSnapshot: () => AVAILABLE,
9
35
  subscribe: () => ({ unsubscribe() {} })
10
36
  };
11
37
  /** Open a session for a target. */
12
38
  function createSession(app, options) {
13
- const view = viewOf(catalogOf(app, { toJsonSchema: options.toJsonSchema }), options.target);
14
39
  const lifetime = new AbortController();
15
40
  const running = /* @__PURE__ */ new Set();
16
41
  const listeners = /* @__PURE__ */ new Set();
17
- const subscriptions = [];
42
+ let subscriptions = [];
18
43
  const onDefect = options.onDefect ?? ((id, cause) => console.error(`A capability failed with a defect: ${id}`, cause));
19
44
  const served = /* @__PURE__ */ new Map();
20
45
  for (const capability of app.capabilities) {
21
- if (!(capability.id in view.capabilities)) continue;
46
+ if (!capability.audience.includes(options.target)) continue;
47
+ if (served.has(capability.id)) throw new Error(`The capability ${capability.id} is declared two times.`);
22
48
  const availability = capability.availability?.(options.context) ?? always;
23
- subscriptions.push(availability.subscribe(() => listeners.forEach((listener) => listener())));
24
49
  served.set(capability.id, {
25
50
  capability,
26
51
  availability
@@ -77,26 +102,41 @@ function createSession(app, options) {
77
102
  handles.set(id, {
78
103
  id,
79
104
  description: capability.description,
80
- audience: capability.audience,
105
+ audience: [options.target],
81
106
  availability,
82
107
  ...state === void 0 ? {} : { snapshot: detached(state) },
83
108
  execute: (input, callOptions) => reject(call(id, input, true, callOptions?.signal)),
84
109
  invokeExternal: (input, callOptions) => reject(call(id, input, false, callOptions?.signal))
85
110
  });
86
111
  }
112
+ const unsubscribeAll = () => {
113
+ subscriptions.forEach((subscription) => subscription.unsubscribe());
114
+ subscriptions = [];
115
+ };
116
+ let view;
87
117
  return {
88
118
  target: options.target,
89
- view,
119
+ get view() {
120
+ return view ??= viewOf(catalogOf(app, { toJsonSchema: options.toJsonSchema }), options.target);
121
+ },
90
122
  handles,
91
123
  signal: lifetime.signal,
92
124
  invoke: (id, input, callOptions) => call(id, input, false, callOptions?.signal),
93
125
  onChange(listener) {
126
+ if (lifetime.signal.aborted) return { unsubscribe() {} };
127
+ if (listeners.size === 0) {
128
+ const announce = () => listeners.forEach((each) => each());
129
+ subscriptions = [...served.values()].map(({ availability }) => availability.subscribe(announce));
130
+ }
94
131
  listeners.add(listener);
95
- return { unsubscribe: () => listeners.delete(listener) };
132
+ return { unsubscribe() {
133
+ if (listeners.delete(listener) && listeners.size === 0) unsubscribeAll();
134
+ } };
96
135
  },
97
136
  async dispose() {
98
137
  lifetime.abort();
99
- subscriptions.forEach((subscription) => subscription.unsubscribe());
138
+ listeners.clear();
139
+ unsubscribeAll();
100
140
  await Promise.allSettled([...running]);
101
141
  }
102
142
  };
@@ -136,4 +176,4 @@ async function checkResult(capability, result) {
136
176
  };
137
177
  }
138
178
  //#endregion
139
- export { createSession };
179
+ export { createSession, getHandle };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acid-cat/core-ts",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Serve AcidCat capabilities in plain TypeScript, with no Effect dependency.",
5
5
  "keywords": [
6
6
  "acidcat",
@@ -32,11 +32,11 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@acid-cat/catalog": "0.3.0",
36
- "@acid-cat/spec-ts": "0.3.0"
35
+ "@acid-cat/catalog": "0.4.0",
36
+ "@acid-cat/spec-ts": "0.4.0"
37
37
  },
38
38
  "devDependencies": {
39
- "@acid-cat/cli": "0.3.0",
39
+ "@acid-cat/cli": "0.4.0",
40
40
  "@types/node": "26.6.5",
41
41
  "esbuild": "0.28.2",
42
42
  "playwright": "1.64.0",