@acid-cat/core-ts 0.8.0 → 0.10.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 +165 -11
- package/dist/app.d.ts +73 -1
- package/dist/app.js +34 -2
- package/dist/cli.d.ts +4 -4
- package/dist/cli.js +111 -16
- package/dist/clock.d.ts +14 -0
- package/dist/clock.js +26 -0
- package/dist/context.d.ts +8 -0
- package/dist/context.js +14 -9
- package/dist/http.d.ts +4 -0
- package/dist/http.js +112 -30
- package/dist/index.d.ts +15 -5
- package/dist/index.js +6 -2
- package/dist/mcp-events.d.ts +87 -0
- package/dist/mcp-events.js +129 -0
- package/dist/mcp-poll.d.ts +28 -0
- package/dist/mcp-poll.js +179 -0
- package/dist/mcp-stdio.d.ts +41 -0
- package/dist/mcp-stdio.js +325 -0
- package/dist/mcp.d.ts +51 -2
- package/dist/mcp.js +105 -24
- package/dist/node.d.ts +2 -0
- package/dist/node.js +5 -1
- package/dist/session.d.ts +53 -3
- package/dist/session.js +129 -1
- package/dist/stream.d.ts +25 -0
- package/dist/stream.js +63 -0
- package/dist/subscription.d.ts +39 -0
- package/dist/subscription.js +145 -0
- package/dist/webhook.d.ts +40 -0
- package/dist/webhook.js +385 -0
- package/package.json +7 -5
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @acid-cat/core-ts
|
|
2
2
|
|
|
3
|
-
Serve AcidCat capabilities in plain TypeScript. npm package: `@acid-cat/core-ts`.
|
|
3
|
+
Serve AcidCat capabilities and events in plain TypeScript. npm package: `@acid-cat/core-ts`.
|
|
4
4
|
|
|
5
|
-
This package has no Effect dependency. A capability returns a promise. Inputs are validated with any library that implements [Standard Schema](https://standardschema.dev), for example Zod, Valibot or ArkType. The application writes its own catalog.
|
|
5
|
+
This package has no Effect dependency. A capability returns a promise. An event gives an async iterable. Inputs are validated with any library that implements [Standard Schema](https://standardschema.dev), for example Zod, Valibot or ArkType. The application writes its own catalog.
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
pnpm add @acid-cat/core-ts
|
|
@@ -42,6 +42,39 @@ export const aircon = defineApp<{ unit: Unit }>({
|
|
|
42
42
|
- `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`.
|
|
43
43
|
- An application lists only capabilities that `defineCapability` or `capabilitiesFor` made. Each one has `decode`, which validates an input that is not trusted with the input schema and then gives the call of `run`. A session calls it, because there the type of the input is not known.
|
|
44
44
|
|
|
45
|
+
## Write events
|
|
46
|
+
|
|
47
|
+
An [event](../../spec/events.md) is a kind of thing that happens, and that clients can follow. `subscribe` gives an async iterable, for example an async generator, with one value for each occurrence.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { eventsFor } from "@acid-cat/core-ts";
|
|
51
|
+
|
|
52
|
+
const Status = z.object({ on: z.boolean(), celsius: z.number() }).meta({ id: "Status" });
|
|
53
|
+
const event = eventsFor<{ unit: Unit }>();
|
|
54
|
+
|
|
55
|
+
export const statusChanged = event({
|
|
56
|
+
id: "aircon.status.changed",
|
|
57
|
+
description: "The status of the unit changed. The occurrence gives the new status.",
|
|
58
|
+
audience: ["app", "mcp"],
|
|
59
|
+
input: z.object({}),
|
|
60
|
+
payload: Status,
|
|
61
|
+
async *subscribe(_input, { context, signal }) {
|
|
62
|
+
for await (const status of context.unit.changes(signal)) yield { data: status };
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export const aircon = defineApp<{ unit: Unit }>({ targets, capabilities: [powerSet], events: [statusChanged] });
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- `subscribe` gets the input, and `{ signal, context, cursor, maxAgeMs }`. The signal aborts when the subscription ends, in all ways. Stop the work and release the resources then.
|
|
70
|
+
- Each value is `{ data }`, and optionally `eventId`, `timestamp`, `cursor` and `truncated`. Give the `eventId` of the source if it has one. With none, the session makes a unique id. With no `timestamp`, the session uses the time at which it gets the value.
|
|
71
|
+
- `data` must agree with the payload schema. The session gives the client the output of the schema, as a copy. With no payload schema, `data` must be an empty object.
|
|
72
|
+
- If `subscribe` throws before it gives an iterable, the subscription fails as `internal` before it starts. If the iterable throws, or gives data that does not agree with the payload schema, the subscription ends with `internal`. The details go to `onDefect`, not to the client.
|
|
73
|
+
- When the iterable ends, the subscription is complete.
|
|
74
|
+
- The session asks for the first value before the promise of `subscribe` resolves. Thus the body of an async generator runs until its first `await` or `yield` before the client gets the iterable. Register a listener on the source before that point, and the subscription gets each change after `subscribe` resolves.
|
|
75
|
+
- To resume, give a `cursor` with each value. A client can give a `cursor` and `maxAgeMs` when it subscribes. Start after the cursor, and send no occurrence older than `maxAgeMs`. If you skip occurrences, give `truncated: true` on the first value. An event that gives no cursors ignores them.
|
|
76
|
+
- `defineEvent` and `eventsFor` keep the literal type of `id`. Capabilities and events share one set of ids.
|
|
77
|
+
|
|
45
78
|
## Write the catalog
|
|
46
79
|
|
|
47
80
|
```ts
|
|
@@ -51,20 +84,93 @@ const toJsonSchema = (schema) => z.toJSONSchema(schema, { target: "draft-2020-12
|
|
|
51
84
|
await writeFile("acidcat.catalog.json", renderCatalog(aircon, { toJsonSchema })); // canonical form
|
|
52
85
|
```
|
|
53
86
|
|
|
54
|
-
Standard Schema does not convert to JSON Schema. Give the converter of your schema library as `toJsonSchema`.
|
|
87
|
+
Standard Schema does not convert to JSON Schema. Give the converter of your schema library as `toJsonSchema`. The catalog has an `events` member when the application has events.
|
|
55
88
|
|
|
56
|
-
## Serve the capabilities
|
|
89
|
+
## Serve the capabilities and the events
|
|
57
90
|
|
|
58
91
|
| Function | Binding | Result |
|
|
59
92
|
| --- | --- | --- |
|
|
60
|
-
| `createSession(app, options)` | [TypeScript](../../spec/bindings/typescript.md) | A session with a handle for each capability in the view |
|
|
61
|
-
| `mcpHandler(app, options)` | [MCP](../../spec/bindings/mcp.md) | `(request: Request) => Promise<Response>` |
|
|
93
|
+
| `createSession(app, options)` | [TypeScript](../../spec/bindings/typescript.md) | A session with a handle for each capability and an event handle for each event in the view |
|
|
94
|
+
| `mcpHandler(app, options)` | [MCP](../../spec/bindings/mcp.md#http) through HTTP | `(request: Request) => Promise<Response>` |
|
|
95
|
+
| `mcpHandshake(handler)` | [MCP](../../spec/bindings/mcp.md#earlier-revisions-through-http) through HTTP, for earlier revisions | The handler, which also serves the `initialize` handshake of earlier revisions. From [`@acid-cat/mcp-handshake`](../mcp-handshake) |
|
|
96
|
+
| `mcpStdioMain(app, options)`, `runMcpStdio(…)` | [MCP](../../spec/bindings/mcp.md#stdio) through stdio | The MCP server of the process, or a promise that resolves when the input ends |
|
|
62
97
|
| `httpHandler(app, options)` | [HTTP](../../spec/bindings/http.md) | `(request: Request) => Promise<Response>` |
|
|
63
98
|
| `cliMain(app, options)`, `runCli(…)` | [CLI](../../spec/bindings/cli.md) | The command line of the process, or an exit code |
|
|
64
|
-
| `installWebMcp(session)` | [WebMCP](../../spec/bindings/webmcp.md) | Tools in the page, with a status |
|
|
99
|
+
| `installWebMcp(session)` | [WebMCP](../../spec/bindings/webmcp.md) | Tools in the page, with a status. WebMCP serves no events |
|
|
65
100
|
| `serve(handler, options)`, from `@acid-cat/core-ts/node` | MCP and HTTP | A Node.js server for the web handlers. See [Serve on Node.js](#serve-on-nodejs) |
|
|
66
101
|
|
|
67
|
-
When the signal of a request aborts, `mcpHandler` and `httpHandler` cancel the call of the request.
|
|
102
|
+
When the signal of a request aborts, `mcpHandler` and `httpHandler` cancel the call or the subscription of the request.
|
|
103
|
+
|
|
104
|
+
Each binding serves the events of its view:
|
|
105
|
+
|
|
106
|
+
| Binding | Events |
|
|
107
|
+
| --- | --- |
|
|
108
|
+
| TypeScript | `session.eventHandle(event).subscribe(input)`. See [Event handles](#event-handles) |
|
|
109
|
+
| HTTP | `GET {base}/events` lists them. `POST {base}/events/{id}` gives a stream of server-sent events: `event: next` for each occurrence, then `event: complete`, or `event: error` with `internal`. `Last-Event-ID` gives the cursor, and `?maxAgeMs=` gives `maxAgeMs`. The option `keepaliveMs` (15 seconds) sets the time after which an idle stream gets a comment line |
|
|
110
|
+
| MCP | The MCP events extension, revision `2026-07-28` only: `events/list`, push delivery with `events/stream`, poll delivery with `events/poll`, and webhook delivery with `events/subscribe` and `events/unsubscribe` through HTTP if the deployment turns it on. Through HTTP, the answer to `events/stream` is a stream of server-sent events. The option `heartbeatMs` (15 seconds) sets the time after which an idle stream gets `notifications/events/heartbeat`. A client with the `initialize` handshake gets no events. See [MCP poll and webhook delivery](#mcp-poll-and-webhook-delivery) |
|
|
111
|
+
| CLI | `watch <id>` writes each occurrence on one line. See [Command line](#command-line) |
|
|
112
|
+
|
|
113
|
+
The options `bufferLimit` of `createSession`, `httpHandler`, `mcpHandler` and `runMcpStdio` set the number of occurrences that each subscription keeps for a client that reads slowly. The default is 256. When the limit is full, the oldest occurrence goes, and the next occurrence that the client reads has `truncated: true`.
|
|
114
|
+
|
|
115
|
+
### MCP poll and webhook delivery
|
|
116
|
+
|
|
117
|
+
A poll lease and a webhook subscription continue after the request that started them. Each one holds its subscription in a session of its own. Through HTTP, that session has the context of the request that started it: the shared `context`, or a new one from `createContext`. Through stdio, it has the `context` of the process. The session closes when the lease or the webhook subscription ends. This state is in memory: when the process stops, it is lost, and clients start again with their cursors.
|
|
118
|
+
|
|
119
|
+
`handler.close()` of `mcpHandler` ends all leases and webhook subscriptions. `await using handler = mcpHandler(…)` calls it at the end of the block. Through stdio, the end of standard input ends them.
|
|
120
|
+
|
|
121
|
+
**Poll.** `events/poll` reads the occurrences of a lease (spec/bindings/mcp.md#poll). Both `mcpHandler` and `runMcpStdio` serve it. The key of a lease is the principal, the id and the input, so two polls of the same client with the same input read one lease. The option `poll` sets the limits:
|
|
122
|
+
|
|
123
|
+
| Option of `poll` | Default | Use |
|
|
124
|
+
| --- | --- | --- |
|
|
125
|
+
| `leaseMs` | 5 minutes | A lease that gets no poll for this time ends, and its subscription is cancelled |
|
|
126
|
+
| `bufferLimit` | 1000 | The number of occurrences that each lease keeps. When the buffer is full, the oldest occurrence goes. A cursor older than the buffer gets `truncated: true` |
|
|
127
|
+
| `maxEvents` | 100 | The number of occurrences in a result when the request gives no `maxEvents` |
|
|
128
|
+
| `nextPollMs` | 2000 | The `nextPollMs` of each result |
|
|
129
|
+
|
|
130
|
+
The cursor of a poll tells the lease, the position in its buffer, and the last cursor of the provider. When a lease ended, a poll with its cursor starts a new lease that resumes after that cursor of the provider, with `maxAgeMs`.
|
|
131
|
+
|
|
132
|
+
**Principal.** The option `principal(request)` of `mcpHandler` gives the identity of the client of a request, for example the subject of a verified token, or `undefined` when the request has none. It is part of the key of a lease and of a webhook subscription, so that one client cannot read the lease of another. Through stdio, the process serves one client, and leases have no principal.
|
|
133
|
+
|
|
134
|
+
**Webhook.** Webhook delivery is off by default, and only through HTTP. Turn it on with the option `webhooks`, and `webhookDelivery(options)` from `@acid-cat/core-ts/node`. It sends each occurrence in a signed `POST` request to the URL of the client (spec/bindings/mcp.md#webhook). It needs `principal`: a request with no principal gets `Forbidden`.
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import { mcpHandler } from "@acid-cat/core-ts";
|
|
138
|
+
import { serve, webhookDelivery } from "@acid-cat/core-ts/node";
|
|
139
|
+
|
|
140
|
+
const mcp = mcpHandler(aircon, {
|
|
141
|
+
name: "aircon",
|
|
142
|
+
version: "1.0.0",
|
|
143
|
+
allowedOrigins: [],
|
|
144
|
+
toJsonSchema,
|
|
145
|
+
context: { unit },
|
|
146
|
+
principal: (request) => verifiedSubject(request), // string | undefined
|
|
147
|
+
webhooks: webhookDelivery({ maxTtlMs: 3_600_000 }),
|
|
148
|
+
});
|
|
149
|
+
await serve({ "/mcp": mcp });
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
| Option of `webhookDelivery` | Default | Use |
|
|
153
|
+
| --- | --- | --- |
|
|
154
|
+
| `allow(url)` | None | The allowlist. A URL for which it gives `true` gets deliveries with no verification |
|
|
155
|
+
| `allowPrivateNetworks` | `false` | Connect also to addresses that are not globally routable. Only for tests and private networks |
|
|
156
|
+
| `ca` | None | Certificates of authorities, in PEM form, to trust beside the default ones |
|
|
157
|
+
| `maxTtlMs`, `minTtlMs` | 1 hour, 1 minute | The limits of the lifetime that the server grants for `ttlMs` |
|
|
158
|
+
| `maxSubscriptions` | 100 | The maximum number of webhook subscriptions of one principal. Over it, `ResourceExhausted` |
|
|
159
|
+
| `attempts`, `retryWindowMs` | 5, 15 minutes | The attempts of one delivery, and the time in which the server makes them |
|
|
160
|
+
| `retryDelayMs(attempt)` | 10 s, 30 s, 90 s, 270 s | The wait before the next attempt |
|
|
161
|
+
| `timeoutMs` | 5 seconds | The time that the server waits for each answer |
|
|
162
|
+
| `rotationGraceMs` | 5 minutes | After a new secret, the time in which the server signs with the old secret and the new secret |
|
|
163
|
+
|
|
164
|
+
Each pair of principal and URL must agree to get deliveries before the first delivery: the URL is in the allowlist, or the endpoint answers the `verification` message with its challenge. The server keeps each verification while the process runs. A refresh with the same key grants a new lifetime and replaces the secret. The server sends the deliveries of a subscription one at a time, in order. The `cursor` of each delivery is the watermark: the last cursor of an occurrence whose delivery succeeded or was abandoned.
|
|
165
|
+
|
|
166
|
+
Security of webhook delivery:
|
|
167
|
+
|
|
168
|
+
- The URL must use `https`. The server checks the certificate of the endpoint.
|
|
169
|
+
- For each request, the server resolves the host name, and connects only to an address that is globally routable, unless `allowPrivateNetworks` is `true`. It connects to the address that it checked, with the host name in the `Host` header and in TLS, so a change of DNS between the check and the connection has no effect. It does not follow redirects.
|
|
170
|
+
- `lastError` and the reason of `CallbackEndpointError` are categories, for example `http_4xx` or `tls_error`. The server gives no text of the answer of the endpoint.
|
|
171
|
+
- The server never logs or returns a secret.
|
|
172
|
+
|
|
173
|
+
The option `clock` of `mcpHandler`, `runMcpStdio` and `webhookDelivery` gives the time and the timers of leases, lifetimes and retries. The default is the clock of the system. Tests give a clock that they control.
|
|
68
174
|
|
|
69
175
|
### Serve on Node.js
|
|
70
176
|
|
|
@@ -89,7 +195,22 @@ console.log(server.url); // http://127.0.0.1:3000
|
|
|
89
195
|
|
|
90
196
|
`serve` resolves when the server listens. `server.close()` stops it, and `await using server = await serve(…)` closes it at the end of the block. When a client closes its connection, the signal of its request aborts, so the handler cancels the call.
|
|
91
197
|
|
|
92
|
-
`serve` gives a plain HTTP/1.1 server.
|
|
198
|
+
`serve` gives a plain HTTP/1.1 server. It does not serve HTTPS. When clients connect through a network, end TLS before the server, in the platform: a load balancer, the ingress of a Kubernetes cluster (for example with cert-manager for the certificates), or a tunnel such as Cloudflare Tunnel. The platform sends plain HTTP to the server on its private network. Set `hostname` to `0.0.0.0` or `::`, so that the platform can connect. Do not put a TLS proxy in the process in front of `serve`.
|
|
199
|
+
|
|
200
|
+
For more, use a library for web handlers, for example [srvx](https://srvx.h3.dev): HTTPS in the process, when TLS must reach the server, HTTP/2, middleware, or the same server code on Bun and Deno. Give it the handler. Make sure that the library aborts the signal of a request when the client closes the connection. If it does not, a closed connection does not cancel the call.
|
|
201
|
+
|
|
202
|
+
### MCP through stdio
|
|
203
|
+
|
|
204
|
+
An MCP client, for example a coding agent, can start the server as a local process. `mcpStdioMain` serves the standard input and output of the process. The process is one session, with one `context`. The server serves revision `2026-07-28`, and the `initialize` handshake of revisions `2025-11-25` and `2025-06-18`. When the availability of a capability changes, the server sends `notifications/tools/list_changed`. `notifications/cancelled` cancels a call or an `events/stream` request. When standard input ends, the session closes: the calls in progress are cancelled, and each subscription is complete.
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
#!/usr/bin/env node
|
|
208
|
+
import { mcpStdioMain } from "@acid-cat/core-ts";
|
|
209
|
+
|
|
210
|
+
mcpStdioMain(aircon, { name: "aircon", version: "1.0.0", context: { unit }, toJsonSchema });
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Standard output carries only MCP messages. The session writes defects to standard error. Do not write to standard output from a capability. `runMcpStdio(app, options, { input, write })` is the same server on any stream: `input` is an async iterable of bytes or text, for example a `ReadableStream`, and `write` writes one line. It works in all runtimes.
|
|
93
214
|
|
|
94
215
|
### Typed calls and handles
|
|
95
216
|
|
|
@@ -116,6 +237,29 @@ power.availability.subscribe(render);
|
|
|
116
237
|
await power.execute({ on: true });
|
|
117
238
|
```
|
|
118
239
|
|
|
240
|
+
### Event handles
|
|
241
|
+
|
|
242
|
+
`session.eventHandle(event)` gives the event handle of an event (spec/bindings/typescript.md#event-handles), with the types of its schemas. `session.eventHandles` is the map of the event handles by id, for bindings. A handle of the map has the input type `never`: subscribe with `subscribeExternal`, which validates the input.
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
const status = session.eventHandle(statusChanged);
|
|
246
|
+
for await (const occurrence of await status.subscribe({})) {
|
|
247
|
+
render(occurrence.data); // { on: boolean; celsius: number }
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
- The promise of `subscribe` resolves when the subscription has started. Before that, it rejects with a `CallFailureError`: `unknown-capability` for an event that the session does not serve, `invalid-input` for an input that does not agree with the schema, and `cancelled` when the session closes or the signal aborts.
|
|
252
|
+
- The result is an async iterator of occurrences: `eventId`, `name`, `timestamp`, `data`, and `cursor` and `truncated` if they are present. `return()` cancels the subscription, also while a `next()` waits: that `next()` resolves with `done: true`. `await using` also cancels it at the end of the block.
|
|
253
|
+
- The options are `signal`, `cursor` and `maxAgeMs`.
|
|
254
|
+
- When the session closes, each subscription is complete. With `internal`, `next()` rejects with a `CallFailureError`, then resolves with `done: true`.
|
|
255
|
+
- A session can hold any number of subscriptions at the same time. To cancel one does not change the others.
|
|
256
|
+
|
|
257
|
+
A GraphQL server can use an event handle as the source of a subscription. Return the promise of `subscribe` from the `subscribe` resolver of the field. GraphQL calls `return()` when the client ends the subscription, through WebSockets (graphql-ws) or server-sent events (graphql-sse, GraphQL Yoga):
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
subscription: { statusChanged: { subscribe: () => status.subscribe({}), resolve: (occurrence) => occurrence.data } }
|
|
261
|
+
```
|
|
262
|
+
|
|
119
263
|
### Context for each request
|
|
120
264
|
|
|
121
265
|
`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:
|
|
@@ -140,11 +284,20 @@ node cli.ts aircon schedule set --days '[]' #
|
|
|
140
284
|
node cli.ts list # the usage of each command
|
|
141
285
|
```
|
|
142
286
|
|
|
143
|
-
`list` gives a usage line for each capability. The usage of a repeated flag ends with `...`, for example `--days <mon|tue|wed|thu|fri|sat|sun>... [--hours <integer>...]`. See the [CLI binding](../../spec/bindings/cli.md#commands) for all the rules.
|
|
287
|
+
`list` gives a usage line for each capability, then for each event. Each item has `kind`: `"capability"` or `"event"`. The usage of a repeated flag ends with `...`, for example `--days <mon|tue|wed|thu|fri|sat|sun>... [--hours <integer>...]`. See the [CLI binding](../../spec/bindings/cli.md#commands) for all the rules.
|
|
288
|
+
|
|
289
|
+
`watch <id>` subscribes to an event. The flags come from the input schema, with the same rules as for a capability. If the input schema has no top-level `properties` member, `watch <id> '<json>'` takes the input as JSON text. Each occurrence goes to standard output as JSON on one line, with its cursor. The exit code is 0 when the subscription is complete, 130 after an interrupt (`SIGINT`), and 5 after `internal`, with `{ "kind", "message" }` on standard error. A failure before the first occurrence has the exit code of a call.
|
|
290
|
+
|
|
291
|
+
`--cursor <cursor>` and `--max-age-ms <integer>` resume the subscription after a cursor, in the flags form and in the JSON form. A script can keep the last cursor, and resume with it after the program stops. A `--max-age-ms` that is not a whole number exits with 3. These two flags are not properties of the input: if the input schema of an event has a top-level property `cursor` or `max-age-ms`, `runCli` throws when it starts.
|
|
292
|
+
|
|
293
|
+
```sh
|
|
294
|
+
node cli.ts watch aircon.status.changed
|
|
295
|
+
node cli.ts watch aircon.status.changed --cursor 41 --max-age-ms 300000
|
|
296
|
+
```
|
|
144
297
|
|
|
145
298
|
### Commands of the application
|
|
146
299
|
|
|
147
|
-
The option `commands` of `cliMain` and `runCli` adds commands of the application beside the commands of the capabilities, `call` and `
|
|
300
|
+
The option `commands` of `cliMain` and `runCli` adds commands of the application beside the commands of the capabilities, `call`, `list` and `watch`. 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`, `watch`, 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.
|
|
148
301
|
|
|
149
302
|
```ts
|
|
150
303
|
import { cliMain } from "@acid-cat/core-ts";
|
|
@@ -168,3 +321,4 @@ cliMain(aircon, { name: "aircon", version: "0.1.0", context: { unit }, toJsonSch
|
|
|
168
321
|
- `moon run core-ts:test` tests sessions, handles and all bindings with a test application, and runs `acid-cat conform` against all bindings. Each test application is in `test/`:
|
|
169
322
|
- [`test/fixture.ts`](test/fixture.ts) has one capability for each behavior that `acid-cat conform` tests.
|
|
170
323
|
- [`test/kettle.ts`](test/kettle.ts) is an electric kettle, simulated in memory. Its tests examine the catalog that the core writes, named types, state, availability, typed handles and the command line.
|
|
324
|
+
- [`test/sensors.ts`](test/sensors.ts) is a set of temperature sensors, simulated in memory. Its tests examine events through each binding: occurrences, the ends of a subscription, slow clients, cursors, and a GraphQL subscription with graphql-js.
|
package/dist/app.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Availability, CatalogDocument, JsonObject, Result } from "@acid-cat/spec-ts";
|
|
1
|
+
import type { Availability, CatalogDocument, Emitted, JsonObject, Result } from "@acid-cat/spec-ts";
|
|
2
2
|
import type { StandardSchemaV1 } from "./standard-schema.js";
|
|
3
3
|
/** A value that can change, and that a client can follow (spec/bindings/typescript.md#handles). */
|
|
4
4
|
export interface Observable<T> {
|
|
@@ -121,6 +121,76 @@ export interface CapabilityFactory<Context> {
|
|
|
121
121
|
* ```
|
|
122
122
|
*/
|
|
123
123
|
export declare const capabilitiesFor: <Context>() => CapabilityFactory<Context>;
|
|
124
|
+
/**
|
|
125
|
+
* What `subscribe` receives beside the input: the signal of the subscription, the context, and where the subscription
|
|
126
|
+
* starts (spec/events.md#resuming). The signal aborts when the subscription ends, in all ways.
|
|
127
|
+
*/
|
|
128
|
+
export interface Subscription<Context> {
|
|
129
|
+
readonly signal: AbortSignal;
|
|
130
|
+
readonly context: Context;
|
|
131
|
+
/** The cursor of the client. With none, the subscription starts now. */
|
|
132
|
+
readonly cursor?: string | undefined;
|
|
133
|
+
/** The client wants no occurrence that is older than this number of milliseconds. */
|
|
134
|
+
readonly maxAgeMs?: number | undefined;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The function of an event. It gives one value for each occurrence, in order, until the signal aborts or the source
|
|
138
|
+
* has no more occurrences. Each `data` must agree with the payload schema.
|
|
139
|
+
*/
|
|
140
|
+
export type Subscribe<Context, Input, Payload> = (input: Input, subscription: Subscription<Context>) => AsyncIterable<Emitted<NoInfer<Payload>>>;
|
|
141
|
+
/** The members of an event that do not depend on its input. */
|
|
142
|
+
export interface EventParts<Payload, Id extends string> {
|
|
143
|
+
readonly id: Id;
|
|
144
|
+
readonly description: string;
|
|
145
|
+
/** The targets that can use the event. With no audience, no target can use it. */
|
|
146
|
+
readonly audience: readonly string[];
|
|
147
|
+
/** The schema of the `data` of each occurrence: an object schema. With no payload schema, the data is an empty object. */
|
|
148
|
+
readonly payload?: StandardSchemaV1<unknown, Payload> | undefined;
|
|
149
|
+
}
|
|
150
|
+
/** An event with an input schema. `subscribe` receives the output of the schema. */
|
|
151
|
+
export interface EventWithInput<Context, Input, Payload> {
|
|
152
|
+
readonly input: StandardSchemaV1<unknown, Input>;
|
|
153
|
+
readonly subscribe: Subscribe<Context, Input, Payload>;
|
|
154
|
+
}
|
|
155
|
+
/** An event with no input schema. `subscribe` receives an empty object. */
|
|
156
|
+
export interface EventWithoutInput<Context, Payload> {
|
|
157
|
+
readonly input?: undefined;
|
|
158
|
+
readonly subscribe: Subscribe<Context, EmptyInput, Payload>;
|
|
159
|
+
}
|
|
160
|
+
/** An event as an application declares it (spec/events.md). `Input` and `Payload` are the outputs of its schemas. */
|
|
161
|
+
export type EventDeclaration<Context, Input, Payload, Id extends string> = EventParts<Payload, Id> & (EventWithInput<Context, Input, Payload> | EventWithoutInput<Context, Payload>);
|
|
162
|
+
/** The result of `decode` of an event: the issues of an input that is not valid, or the start of the subscription. */
|
|
163
|
+
export type DecodedEvent<Context> = {
|
|
164
|
+
readonly issues: readonly StandardSchemaV1.Issue[];
|
|
165
|
+
} | {
|
|
166
|
+
readonly start: (subscription: Subscription<Context>) => AsyncIterable<Emitted<unknown>>;
|
|
167
|
+
};
|
|
168
|
+
/** An event that `defineEvent` or `eventsFor` made: its declaration, and `decode`. */
|
|
169
|
+
export type EventDefinition<Context = unknown, Input = EmptyInput, Payload = EmptyInput, Id extends string = string> = EventDeclaration<Context, Input, Payload, Id> & {
|
|
170
|
+
readonly decode: (input: unknown) => Promise<DecodedEvent<Context>>;
|
|
171
|
+
};
|
|
172
|
+
/** Any event whose services the context `Context` gives, whatever its schemas are. */
|
|
173
|
+
export interface AnyEventDefinition<Context = never> {
|
|
174
|
+
readonly id: string;
|
|
175
|
+
readonly description: string;
|
|
176
|
+
readonly audience: readonly string[];
|
|
177
|
+
readonly input?: StandardSchemaV1 | undefined;
|
|
178
|
+
readonly payload?: StandardSchemaV1 | undefined;
|
|
179
|
+
readonly decode: (input: unknown) => Promise<DecodedEvent<Context>>;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Declare an event. `Context` is the type of the services that `subscribe` receives. The other types come from the
|
|
183
|
+
* schemas, and the id keeps its literal type.
|
|
184
|
+
*/
|
|
185
|
+
export declare function defineEvent<Context = unknown, Input = EmptyInput, Payload = EmptyInput, const Id extends string = string>(declaration: EventParts<Payload, Id> & EventWithInput<Context, Input, Payload>): EventDefinition<Context, Input, Payload, Id>;
|
|
186
|
+
export declare function defineEvent<Context = unknown, Payload = EmptyInput, const Id extends string = string>(declaration: EventParts<Payload, Id> & EventWithoutInput<Context, Payload>): EventDefinition<Context, EmptyInput, Payload, Id>;
|
|
187
|
+
/** The function that `eventsFor` gives: `defineEvent`, with one context type. */
|
|
188
|
+
export interface EventFactory<Context> {
|
|
189
|
+
<Input, Payload = EmptyInput, const Id extends string = string>(declaration: EventParts<Payload, Id> & EventWithInput<Context, Input, Payload>): EventDefinition<Context, Input, Payload, Id>;
|
|
190
|
+
<Payload = EmptyInput, const Id extends string = string>(declaration: EventParts<Payload, Id> & EventWithoutInput<Context, Payload>): EventDefinition<Context, EmptyInput, Payload, Id>;
|
|
191
|
+
}
|
|
192
|
+
/** Declare events for one context type, as `capabilitiesFor` does for capabilities. */
|
|
193
|
+
export declare const eventsFor: <Context>() => EventFactory<Context>;
|
|
124
194
|
export interface Target {
|
|
125
195
|
readonly bindings: readonly string[];
|
|
126
196
|
readonly description?: string | undefined;
|
|
@@ -133,6 +203,8 @@ export interface App<Context = unknown> {
|
|
|
133
203
|
readonly [name: string]: Target;
|
|
134
204
|
};
|
|
135
205
|
readonly capabilities: readonly AnyCapability<Context>[];
|
|
206
|
+
/** The events (spec/events.md). Capabilities and events share one set of ids. */
|
|
207
|
+
readonly events?: readonly AnyEventDefinition<Context>[] | undefined;
|
|
136
208
|
}
|
|
137
209
|
/** Declare an application. */
|
|
138
210
|
export declare const defineApp: <Context = unknown>(app: App<Context>) => App<Context>;
|
package/dist/app.js
CHANGED
|
@@ -34,6 +34,27 @@ function decoderOf(declaration) {
|
|
|
34
34
|
* ```
|
|
35
35
|
*/
|
|
36
36
|
const capabilitiesFor = () => defineCapability;
|
|
37
|
+
function defineEvent(declaration) {
|
|
38
|
+
if (declaration.input === void 0) {
|
|
39
|
+
const { subscribe } = declaration;
|
|
40
|
+
return {
|
|
41
|
+
...declaration,
|
|
42
|
+
decode: () => Promise.resolve({ start: (subscription) => subscribe({}, subscription) })
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const { input: schema, subscribe } = declaration;
|
|
46
|
+
return {
|
|
47
|
+
...declaration,
|
|
48
|
+
decode: async (input) => {
|
|
49
|
+
const validated = await schema["~standard"].validate(input);
|
|
50
|
+
if (validated.issues !== void 0) return { issues: validated.issues };
|
|
51
|
+
const { value } = validated;
|
|
52
|
+
return { start: (subscription) => subscribe(value, subscription) };
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Declare events for one context type, as `capabilitiesFor` does for capabilities. */
|
|
57
|
+
const eventsFor = () => defineEvent;
|
|
37
58
|
/** Declare an application. */
|
|
38
59
|
const defineApp = (app) => app;
|
|
39
60
|
const catalogs = /* @__PURE__ */ new WeakMap();
|
|
@@ -80,6 +101,16 @@ function write(app, toJsonSchema) {
|
|
|
80
101
|
...capability.state === void 0 ? {} : { state: toJson(capability.state.schema, capability.id) }
|
|
81
102
|
};
|
|
82
103
|
}
|
|
104
|
+
const events = {};
|
|
105
|
+
for (const event of app.events ?? []) {
|
|
106
|
+
if (event.id in capabilities || event.id in events) throw new Error(`The id ${event.id} is declared two times.`);
|
|
107
|
+
events[event.id] = {
|
|
108
|
+
description: event.description,
|
|
109
|
+
audience: event.audience,
|
|
110
|
+
...event.input === void 0 ? {} : { input: objectInput(toJson(event.input, event.id), types) },
|
|
111
|
+
...event.payload === void 0 ? {} : { payload: objectInput(toJson(event.payload, event.id), types) }
|
|
112
|
+
};
|
|
113
|
+
}
|
|
83
114
|
return {
|
|
84
115
|
acidcat: "0.1",
|
|
85
116
|
...app.title === void 0 ? {} : { title: app.title },
|
|
@@ -90,10 +121,11 @@ function write(app, toJsonSchema) {
|
|
|
90
121
|
...target.options === void 0 ? {} : { options: target.options }
|
|
91
122
|
}])),
|
|
92
123
|
...Object.keys(types).length === 0 ? {} : { $defs: types },
|
|
93
|
-
capabilities
|
|
124
|
+
capabilities,
|
|
125
|
+
...Object.keys(events).length === 0 ? {} : { events }
|
|
94
126
|
};
|
|
95
127
|
}
|
|
96
128
|
/** The catalog of an application, as text in canonical form. */
|
|
97
129
|
const renderCatalog = (app, options) => canonicalText(catalogOf(app, options));
|
|
98
130
|
//#endregion
|
|
99
|
-
export { capabilitiesFor, catalogOf, defineApp, defineCapability, renderCatalog };
|
|
131
|
+
export { capabilitiesFor, catalogOf, defineApp, defineCapability, defineEvent, eventsFor, renderCatalog };
|
package/dist/cli.d.ts
CHANGED
|
@@ -11,9 +11,9 @@ export interface CliOptions<Context> {
|
|
|
11
11
|
*/
|
|
12
12
|
readonly description?: string | undefined;
|
|
13
13
|
/**
|
|
14
|
-
* The commands of the application, beside the commands of the capabilities, `call` and `
|
|
15
|
-
* must not be `call`, `list`, the name of another command of the application, or the first word of
|
|
16
|
-
* capability (spec/bindings/cli.md#commands).
|
|
14
|
+
* The commands of the application, beside the commands of the capabilities, `call`, `list` and `watch`. The name of
|
|
15
|
+
* each one must not be `call`, `list`, `watch`, the name of another command of the application, or the first word of
|
|
16
|
+
* the command of a capability (spec/bindings/cli.md#commands).
|
|
17
17
|
*/
|
|
18
18
|
readonly commands?: readonly CliCommand<Context>[] | undefined;
|
|
19
19
|
}
|
|
@@ -32,7 +32,7 @@ export interface CliCommand<Context> {
|
|
|
32
32
|
readonly context: Context;
|
|
33
33
|
}): number | Promise<number>;
|
|
34
34
|
}
|
|
35
|
-
/** Run the command line as the main program of the process. An interrupt (`SIGINT`) cancels the call. */
|
|
35
|
+
/** Run the command line as the main program of the process. An interrupt (`SIGINT`) cancels the call or the subscription. */
|
|
36
36
|
export declare function main<Context>(app: App<Context>, options: CliOptions<Context>): void;
|
|
37
37
|
/** Run the command line with arguments. The result is the exit code (spec/bindings/cli.md#outcomes). */
|
|
38
38
|
export declare function runCli<Context>(app: App<Context>, options: CliOptions<Context>, args: readonly string[], io: CliIo): Promise<number>;
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { createSession } from "./session.js";
|
|
2
|
-
import { CLI_EXIT_CODES, bundle, cliExitCode, cliFlags, cliJsonArray, cliRepeatedItems, cliResolve, cliUsage, commandPath, isCallFailure, isJsonObject } from "@acid-cat/spec-ts";
|
|
2
|
+
import { CLI_EXIT_CODES, CLI_RESUME_FLAGS, CLI_WATCH_EXIT_CODES, CallFailureError, bundle, cliEventUsage, cliExitCode, cliFlags, cliJsonArray, cliRepeatedItems, cliResolve, cliUsage, commandPath, isCallFailure, isJsonObject } from "@acid-cat/spec-ts";
|
|
3
3
|
//#region src/cli.ts
|
|
4
4
|
/**
|
|
5
5
|
* The CLI binding (spec/bindings/cli.md): a command line made from the catalog.
|
|
6
6
|
*/
|
|
7
|
+
/** The commands of the command line itself. No other command can have their names. */
|
|
8
|
+
const GENERIC_COMMANDS = [
|
|
9
|
+
"call",
|
|
10
|
+
"list",
|
|
11
|
+
"watch"
|
|
12
|
+
];
|
|
7
13
|
var UsageError = class extends Error {
|
|
8
14
|
exitCode;
|
|
9
15
|
constructor(message, exitCode) {
|
|
@@ -11,7 +17,7 @@ var UsageError = class extends Error {
|
|
|
11
17
|
this.exitCode = exitCode;
|
|
12
18
|
}
|
|
13
19
|
};
|
|
14
|
-
/** Run the command line as the main program of the process. An interrupt (`SIGINT`) cancels the call. */
|
|
20
|
+
/** Run the command line as the main program of the process. An interrupt (`SIGINT`) cancels the call or the subscription. */
|
|
15
21
|
function main(app, options) {
|
|
16
22
|
const interrupt = new AbortController();
|
|
17
23
|
process.once("SIGINT", () => interrupt.abort());
|
|
@@ -35,30 +41,82 @@ async function runCli(app, options, args, io) {
|
|
|
35
41
|
id,
|
|
36
42
|
path: commandPath(id)
|
|
37
43
|
}));
|
|
38
|
-
for (const { path } of commands) if (path[0]
|
|
44
|
+
for (const { path } of commands) if (path[0] !== void 0 && GENERIC_COMMANDS.includes(path[0])) throw new Error(`The command ${path.join(" ")} has the same name as a command of the command line.`);
|
|
45
|
+
for (const [id, event] of Object.entries(session.view.events ?? {})) {
|
|
46
|
+
const properties = event.input === void 0 ? void 0 : bundle(event.input, types).properties;
|
|
47
|
+
const taken = CLI_RESUME_FLAGS.find((flag) => isJsonObject(properties) && flag in properties);
|
|
48
|
+
if (taken !== void 0) throw new Error(`The input of the event ${id} has a property ${taken}, which is a flag of watch.`);
|
|
49
|
+
}
|
|
39
50
|
const added = options.commands ?? [];
|
|
40
|
-
const taken = /* @__PURE__ */ new Set([
|
|
41
|
-
"call",
|
|
42
|
-
"list",
|
|
43
|
-
...commands.flatMap(({ path }) => path.slice(0, 1))
|
|
44
|
-
]);
|
|
51
|
+
const taken = /* @__PURE__ */ new Set([...GENERIC_COMMANDS, ...commands.flatMap(({ path }) => path.slice(0, 1))]);
|
|
45
52
|
for (const command of added) {
|
|
46
53
|
if (taken.has(command.name)) throw new Error(`The command ${command.name} of the application has the same name as another command.`);
|
|
47
54
|
taken.add(command.name);
|
|
48
55
|
}
|
|
56
|
+
const printFailure = (failure) => io.stderr(JSON.stringify({
|
|
57
|
+
kind: failure.kind,
|
|
58
|
+
message: failure.message
|
|
59
|
+
}));
|
|
49
60
|
const print = (outcome) => {
|
|
50
|
-
if (isCallFailure(outcome))
|
|
51
|
-
kind: outcome.kind,
|
|
52
|
-
message: outcome.message
|
|
53
|
-
}));
|
|
61
|
+
if (isCallFailure(outcome)) printFailure(outcome);
|
|
54
62
|
else io.stdout(JSON.stringify(outcome.ok ? outcome.value : outcome.error, null, 2));
|
|
55
63
|
return cliExitCode(outcome);
|
|
56
64
|
};
|
|
65
|
+
/** Subscribe to an event, and write each occurrence on one line (spec/bindings/cli.md#events). */
|
|
66
|
+
const watch = async (rest) => {
|
|
67
|
+
const [id, ...args] = rest;
|
|
68
|
+
const { flagArgs, resume } = resumeFlags(args);
|
|
69
|
+
if (id === void 0) throw new UsageError("Give the id of an event.", CLI_EXIT_CODES["invalid-input"]);
|
|
70
|
+
const event = session.view.events?.[id];
|
|
71
|
+
let input = {};
|
|
72
|
+
if (event !== void 0) {
|
|
73
|
+
const schema = bundle(event.input ?? { type: "object" }, types);
|
|
74
|
+
if (flagArgs.includes("--help") || flagArgs.includes("-h")) {
|
|
75
|
+
io.stdout([
|
|
76
|
+
`Usage: ${cliEventUsage(options.name, id, event.input === void 0 ? void 0 : schema)}`,
|
|
77
|
+
"",
|
|
78
|
+
event.description
|
|
79
|
+
].join("\n"));
|
|
80
|
+
return CLI_EXIT_CODES.value;
|
|
81
|
+
}
|
|
82
|
+
if (schema.properties !== void 0) input = parseFlags(flagArgs, schema);
|
|
83
|
+
else {
|
|
84
|
+
const [text = "{}", extra] = flagArgs;
|
|
85
|
+
if (extra !== void 0) throw new UsageError(`Unexpected argument ${extra}.`, CLI_EXIT_CODES["invalid-input"]);
|
|
86
|
+
try {
|
|
87
|
+
input = JSON.parse(text);
|
|
88
|
+
} catch {
|
|
89
|
+
return print({
|
|
90
|
+
kind: "invalid-input",
|
|
91
|
+
message: "The input is not JSON."
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const subscribed = await session.subscribe(id, input, {
|
|
97
|
+
signal: io.signal,
|
|
98
|
+
...resume
|
|
99
|
+
});
|
|
100
|
+
if ("kind" in subscribed) return print(subscribed);
|
|
101
|
+
try {
|
|
102
|
+
for await (const occurrence of subscribed) io.stdout(JSON.stringify(occurrence));
|
|
103
|
+
} catch (error) {
|
|
104
|
+
printFailure(error instanceof CallFailureError ? {
|
|
105
|
+
kind: error.kind,
|
|
106
|
+
message: error.message
|
|
107
|
+
} : {
|
|
108
|
+
kind: "internal",
|
|
109
|
+
message: "The event failed."
|
|
110
|
+
});
|
|
111
|
+
return CLI_WATCH_EXIT_CODES.internal;
|
|
112
|
+
}
|
|
113
|
+
return io.signal?.aborted === true ? CLI_WATCH_EXIT_CODES.cancelled : CLI_WATCH_EXIT_CODES.complete;
|
|
114
|
+
};
|
|
57
115
|
try {
|
|
58
116
|
const [first, ...rest] = args;
|
|
59
117
|
if (first === void 0 || first === "--help" || first === "-h") {
|
|
60
118
|
const description = options.description ?? app.description ?? app.title ?? options.name;
|
|
61
|
-
io.stdout(help(options.name, description, commands.map(({ path }) => path.join(" ")), added));
|
|
119
|
+
io.stdout(help(options.name, description, commands.map(({ path }) => path.join(" ")), Object.keys(session.view.events ?? {}).length > 0, added));
|
|
62
120
|
return CLI_EXIT_CODES.value;
|
|
63
121
|
}
|
|
64
122
|
if (first === "--version" || first === "-v") {
|
|
@@ -66,15 +124,21 @@ async function runCli(app, options, args, io) {
|
|
|
66
124
|
return CLI_EXIT_CODES.value;
|
|
67
125
|
}
|
|
68
126
|
if (first === "list") {
|
|
69
|
-
io.stdout(JSON.stringify([...session.handles.values()].map((handle) => {
|
|
127
|
+
io.stdout(JSON.stringify([...[...session.handles.values()].map((handle) => {
|
|
70
128
|
const input = viewCapability(session.view, handle.id).input;
|
|
71
129
|
return {
|
|
130
|
+
kind: "capability",
|
|
72
131
|
id: handle.id,
|
|
73
132
|
description: handle.description,
|
|
74
133
|
...handle.availability.getSnapshot(),
|
|
75
134
|
usage: cliUsage(options.name, handle.id, input === void 0 ? void 0 : bundle(input, types))
|
|
76
135
|
};
|
|
77
|
-
}),
|
|
136
|
+
}), ...Object.entries(session.view.events ?? {}).map(([id, event]) => ({
|
|
137
|
+
kind: "event",
|
|
138
|
+
id,
|
|
139
|
+
description: event.description,
|
|
140
|
+
usage: cliEventUsage(options.name, id, event.input === void 0 ? void 0 : bundle(event.input, types))
|
|
141
|
+
}))], null, 2));
|
|
78
142
|
return CLI_EXIT_CODES.value;
|
|
79
143
|
}
|
|
80
144
|
if (first === "call") {
|
|
@@ -91,6 +155,7 @@ async function runCli(app, options, args, io) {
|
|
|
91
155
|
}
|
|
92
156
|
return print(await session.invoke(id, input, { signal: io.signal }));
|
|
93
157
|
}
|
|
158
|
+
if (first === "watch") return await watch(rest);
|
|
94
159
|
const command = added.find(({ name }) => name === first);
|
|
95
160
|
if (command !== void 0) return await command.run(rest, {
|
|
96
161
|
...io,
|
|
@@ -114,6 +179,35 @@ async function runCli(app, options, args, io) {
|
|
|
114
179
|
await session.dispose();
|
|
115
180
|
}
|
|
116
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Take `--cursor` and `--max-age-ms` out of the arguments of `watch` (spec/bindings/cli.md#events). The result is the
|
|
184
|
+
* other arguments, and the cursor and `maxAgeMs` of the subscription.
|
|
185
|
+
*/
|
|
186
|
+
function resumeFlags(args) {
|
|
187
|
+
const flagArgs = [];
|
|
188
|
+
const resume = {};
|
|
189
|
+
const remaining = args.values();
|
|
190
|
+
for (const arg of remaining) {
|
|
191
|
+
const [name = "", inline] = arg.startsWith("--") ? arg.slice(2).split(/=(.*)/s, 2) : [];
|
|
192
|
+
if (name !== "cursor" && name !== "max-age-ms") {
|
|
193
|
+
flagArgs.push(arg);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const text = inline ?? remaining.next().value;
|
|
197
|
+
if (text === void 0) throw new UsageError(`The flag --${name} needs a value.`, CLI_EXIT_CODES["invalid-input"]);
|
|
198
|
+
if (name === "cursor") {
|
|
199
|
+
resume.cursor = text;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const maxAgeMs = Number(text);
|
|
203
|
+
if (!/^[0-9]+$/.test(text) || !Number.isSafeInteger(maxAgeMs)) throw new UsageError("The flag --max-age-ms must be a whole number of milliseconds.", CLI_EXIT_CODES["invalid-input"]);
|
|
204
|
+
resume.maxAgeMs = maxAgeMs;
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
flagArgs,
|
|
208
|
+
resume
|
|
209
|
+
};
|
|
210
|
+
}
|
|
117
211
|
/** Make the input from the flags (spec/bindings/cli.md#commands). */
|
|
118
212
|
function parseFlags(args, schema) {
|
|
119
213
|
const properties = objectOrEmpty(schema.properties);
|
|
@@ -174,7 +268,7 @@ function viewCapability(view, id) {
|
|
|
174
268
|
if (capability === void 0) throw new Error(`The view of the session has no capability ${id}.`);
|
|
175
269
|
return capability;
|
|
176
270
|
}
|
|
177
|
-
function help(name, description, paths, added) {
|
|
271
|
+
function help(name, description, paths, hasEvents, added) {
|
|
178
272
|
return [
|
|
179
273
|
description,
|
|
180
274
|
"",
|
|
@@ -184,6 +278,7 @@ function help(name, description, paths, added) {
|
|
|
184
278
|
...paths.map((path) => ` ${path}`),
|
|
185
279
|
" call <id> [input]",
|
|
186
280
|
" list",
|
|
281
|
+
...hasEvents ? [" watch <id> [flags] [--cursor <string>] [--max-age-ms <integer>]"] : [],
|
|
187
282
|
...added.map((command) => ` ${command.name} ${command.description}`)
|
|
188
283
|
].join("\n");
|
|
189
284
|
}
|
package/dist/clock.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The time of the server: the current time, and timers. Poll leases and webhook subscriptions use it, so that a test
|
|
3
|
+
* can control the time.
|
|
4
|
+
*/
|
|
5
|
+
export interface Clock {
|
|
6
|
+
/** The current time, in milliseconds since the epoch. */
|
|
7
|
+
readonly now: () => number;
|
|
8
|
+
/** Call `callback` after `ms` milliseconds. The result cancels the timer. */
|
|
9
|
+
readonly schedule: (callback: () => void, ms: number) => () => void;
|
|
10
|
+
}
|
|
11
|
+
/** The clock of the system: `Date.now` and `setTimeout`. */
|
|
12
|
+
export declare const systemClock: Clock;
|
|
13
|
+
/** Wait `ms` milliseconds on a clock. The wait ends early, with `false`, when the signal aborts. */
|
|
14
|
+
export declare function sleep(clock: Clock, ms: number, signal: AbortSignal): Promise<boolean>;
|