holon-sh 0.0.1-dev.11 → 0.0.1-dev.13
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 +2 -30
- package/dist/ext/events.d.ts +15 -155
- package/dist/ext/events.d.ts.map +1 -1
- package/dist/ext/events.js +103 -218
- package/dist/ext/events.js.map +1 -1
- package/dist/ext/files.client.d.ts +1 -1
- package/dist/ext/files.client.d.ts.map +1 -1
- package/dist/ext/files.client.js +2 -2
- package/dist/ext/files.client.js.map +1 -1
- package/dist/protocol.js +1 -1
- package/dist/protocol.js.map +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +20 -13
- package/dist/runtime.js.map +1 -1
- package/package.json +1 -2
- package/src/ext/events.ts +140 -328
- package/src/ext/files.client.ts +2 -2
- package/src/protocol.ts +1 -1
- package/src/runtime.ts +20 -14
- package/dist/ext/webhooks.d.ts +0 -18
- package/dist/ext/webhooks.d.ts.map +0 -1
- package/dist/ext/webhooks.js +0 -527
- package/dist/ext/webhooks.js.map +0 -1
- package/src/ext/webhooks.ts +0 -512
package/src/ext/events.ts
CHANGED
|
@@ -1,19 +1,14 @@
|
|
|
1
1
|
// The server side of MCP Events, as the Triggers & Events Working Group's design sketch defines it:
|
|
2
2
|
// https://github.com/modelcontextprotocol/experimental-ext-triggers-events/blob/main/docs/design-sketch-proposal.md
|
|
3
|
-
// The draft is experimental, so this lives under ext and may change with it.
|
|
4
|
-
//
|
|
5
|
-
// that reaches the event types through the internals below. Names follow the sketch, and types are the MCP SDK's own
|
|
6
|
-
// wherever it has them.
|
|
3
|
+
// The draft is experimental, so this lives under ext and may change with it. It serves the poll and push delivery
|
|
4
|
+
// modes. Names follow the sketch, and types are the MCP SDK's own wherever it has them.
|
|
7
5
|
import { ProtocolError, ProtocolErrorCode, SUBSCRIPTION_ID_META_KEY } from "@modelcontextprotocol/server";
|
|
8
6
|
import type { AuthInfo, JSONRPCErrorResponse, McpServer, Server, StandardSchemaWithJSON } from "@modelcontextprotocol/server";
|
|
9
|
-
import { webhooks, type WebhookOptions } from "./webhooks.js";
|
|
10
|
-
|
|
11
|
-
export type { WebhookOptions } from "./webhooks.js";
|
|
12
7
|
|
|
13
8
|
/** The request a subscription came in, as Holon admitted it: read its caller with getContext. */
|
|
14
9
|
export type SubscriptionContext = { authInfo?: AuthInfo | undefined } | undefined;
|
|
15
10
|
/** How a subscription receives its events. */
|
|
16
|
-
export type DeliveryModeName = "poll" | "push"
|
|
11
|
+
export type DeliveryModeName = "poll" | "push";
|
|
17
12
|
/** A JSON-RPC error, as a subscription that is terminated or fails is told it. */
|
|
18
13
|
export type EventError = JSONRPCErrorResponse["error"];
|
|
19
14
|
/** A subscription as it begins: who asked, for what, and by which delivery mode. */
|
|
@@ -39,27 +34,7 @@ export interface EventOccurrence<D = unknown> {
|
|
|
39
34
|
/** When it happened, in ISO 8601. */
|
|
40
35
|
timestamp: string;
|
|
41
36
|
data: D;
|
|
42
|
-
cursor?: string
|
|
43
|
-
_meta?: Record<string, unknown>;
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* What happened after a cursor, as a check function finds it: the sketch's EventResult. Each event is an
|
|
47
|
-
* EventOccurrence whose name is the type's, and whose `eventId` (the source's own, so a client drops one it has seen)
|
|
48
|
-
* and `timestamp` are made up when not given. An event's `cursor`, the position just after it, lets a batch be cut
|
|
49
|
-
* short; without it a batch is delivered whole.
|
|
50
|
-
*/
|
|
51
|
-
export interface EventResult<D> {
|
|
52
|
-
/**
|
|
53
|
-
* Empty when the cursor was null: a null cursor asks only for the current position. A source without positions
|
|
54
|
-
* answers with a null cursor, is asked with null each time, and answers each time with what is new.
|
|
55
|
-
*/
|
|
56
|
-
events: Array<Omit<EventOccurrence<D>, "name" | "eventId" | "timestamp" | "cursor"> & { eventId?: string; timestamp?: string; cursor?: string }>;
|
|
57
|
-
/** The position after these events, or null when the source cannot be resumed from a position. */
|
|
58
|
-
cursor: string | null;
|
|
59
|
-
/** Events between the cursor and these were skipped, for instance because the source no longer keeps them. */
|
|
60
|
-
truncated?: boolean;
|
|
61
|
-
/** More events are ready at once after this batch. */
|
|
62
|
-
hasMore?: boolean;
|
|
37
|
+
cursor?: string;
|
|
63
38
|
}
|
|
64
39
|
/** An event type: the arguments a subscription gives, and the data each occurrence carries. */
|
|
65
40
|
export interface EventDefinition<A, D> {
|
|
@@ -79,14 +54,7 @@ export interface EventDefinition<A, D> {
|
|
|
79
54
|
match?: (data: D, args: A, context: SubscriptionContext) => boolean;
|
|
80
55
|
/** The data a subscription with these arguments receives of an emitted event. Without it, the data as emitted. */
|
|
81
56
|
transform?: (data: D, args: A, context: SubscriptionContext) => unknown;
|
|
82
|
-
/**
|
|
83
|
-
* What happened after a cursor, for a source that keeps its own history: polls are answered by it, and streams ask
|
|
84
|
-
* it every second. The cursor is the source's own, so it outlives the process. A null cursor asks for the
|
|
85
|
-
* current position. The function filters and shapes for the arguments itself; `match` and `transform` apply only to
|
|
86
|
-
* what is emitted. Without it, emitted events are kept in memory and polls and streams are served from there.
|
|
87
|
-
*/
|
|
88
|
-
check?: (cursor: string | null, args: A, context: SubscriptionContext) => EventResult<D> | Promise<EventResult<D>>;
|
|
89
|
-
/** The delivery modes this event type offers. Every mode served unless given, except webhook for a type with a check function, which webhook delivery does not read. */
|
|
57
|
+
/** The delivery modes this event type offers: poll and push unless given. */
|
|
90
58
|
delivery?: DeliveryModeName[];
|
|
91
59
|
/**
|
|
92
60
|
* Whether the caller may subscribe with these arguments. A subscription is refused with Forbidden when it may not,
|
|
@@ -110,33 +78,29 @@ export interface EventDefinition<A, D> {
|
|
|
110
78
|
export interface Emitter<D> {
|
|
111
79
|
/**
|
|
112
80
|
* `eventId` lets a client drop an occurrence it has seen. Give the source's own id when it has one. With
|
|
113
|
-
* `subscriptionId`, the sketch's targeted emit, the occurrence goes to that subscription alone, whatever its
|
|
114
|
-
* other, so a subscription that reconnects or polls again from its cursor still has
|
|
115
|
-
*
|
|
116
|
-
* reconnecting streams learn of it from the check function, and one emitted to a subscription that does not get it
|
|
117
|
-
* on a stream is lost, so that subscription's next poll or stream is told of a gap.
|
|
81
|
+
* `subscriptionId`, the sketch's targeted emit, the occurrence goes to that subscription alone, whatever its
|
|
82
|
+
* arguments; it is kept like any other, so a subscription that reconnects or polls again from its cursor still has
|
|
83
|
+
* it, or is told of a gap.
|
|
118
84
|
*
|
|
119
85
|
* Emitting is synchronous. Occurrences emitted one after another in the same synchronous run reach each
|
|
120
86
|
* subscription in that order, with no other occurrence of this type between them, whether polled or pushed. So a
|
|
121
87
|
* group of changes that nothing may come between is either emitted in one run or sent as one occurrence holding a
|
|
122
88
|
* list. A subscription skips part of a group only when it is told of a gap: the group outgrew the events kept, or
|
|
123
|
-
* the subscription fell behind them.
|
|
124
|
-
* not between what is emitted and what the check function finds, which may arrive later or again.
|
|
89
|
+
* the subscription fell behind them.
|
|
125
90
|
*
|
|
126
91
|
* The data is serialised when it is emitted, so data that cannot be sent as JSON throws here, before anything is
|
|
127
92
|
* kept or sent.
|
|
128
93
|
*/
|
|
129
94
|
emit(data: D, options?: { eventId?: string; timestamp?: string; subscriptionId?: string }): void;
|
|
130
95
|
/**
|
|
131
|
-
* The position after the last occurrence emitted
|
|
132
|
-
*
|
|
133
|
-
* state and none before. A type with a check function has no such position here: its cursor is the source's own,
|
|
134
|
-
* so read the source's head instead. This throws for such a type.
|
|
96
|
+
* The position after the last occurrence emitted. Return it with the state a tool reads, in the same synchronous
|
|
97
|
+
* run, and a client that subscribes from it hears every change after that state and none before.
|
|
135
98
|
*/
|
|
136
99
|
cursor(): string;
|
|
137
100
|
/**
|
|
138
|
-
* Terminates one subscription, or all of this type's: a stream is sent
|
|
139
|
-
* `
|
|
101
|
+
* Terminates one subscription, or all of this type's: a stream is sent what was emitted to it before, then
|
|
102
|
+
* `notifications/events/terminated` with `error`, and closed. The next poll is answered with the error. Forbidden
|
|
103
|
+
* unless given, which ends delivery at once, since access is lost.
|
|
140
104
|
*/
|
|
141
105
|
terminate(subscriptionId?: string, error?: EventError): void;
|
|
142
106
|
/**
|
|
@@ -144,49 +108,31 @@ export interface Emitter<D> {
|
|
|
144
108
|
*/
|
|
145
109
|
remove(): void;
|
|
146
110
|
}
|
|
147
|
-
export interface EventsOptions {
|
|
148
|
-
/**
|
|
149
|
-
* The sketch's webhook_ttl, which turns webhook delivery on: the longest lifetime granted to a webhook
|
|
150
|
-
* subscription, in milliseconds. A client's suggestion is held to it, and every grant expires. Without it, webhook
|
|
151
|
-
* delivery is not offered.
|
|
152
|
-
*/
|
|
153
|
-
webhookTtl?: number;
|
|
154
|
-
/** For tests of webhook delivery. */
|
|
155
|
-
webhook?: WebhookOptions;
|
|
156
|
-
}
|
|
157
|
-
/** A delivery mode besides poll and push: webhook. It registers its requests on each server and is listed by events/list. Not for applications. */
|
|
158
|
-
export interface DeliveryMode {
|
|
159
|
-
readonly name: string;
|
|
160
|
-
serve(server: object, internals: Internals): void;
|
|
161
|
-
}
|
|
162
111
|
/** The event types of a process, served by each MCP server it creates. */
|
|
163
112
|
export interface Events {
|
|
164
113
|
/** Declares an event type. Emit its occurrences with the returned emitter. */
|
|
165
114
|
event<A, D>(name: string, definition: EventDefinition<A, D>): Emitter<D>;
|
|
166
115
|
/** Serves events/list, events/poll and events/stream on an McpServer or a Server, and declares the extension. */
|
|
167
116
|
serve(server: McpServer | Server): void;
|
|
168
|
-
/** What a delivery mode works with. Not for applications. */
|
|
169
|
-
readonly [INTERNALS]: Internals;
|
|
170
117
|
}
|
|
171
118
|
|
|
172
|
-
/**
|
|
173
|
-
export
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
/** Where delivery starts. */
|
|
180
|
-
start: string | null;
|
|
181
|
-
cursor: string | null;
|
|
182
|
-
truncated: boolean;
|
|
183
|
-
hasMore: boolean;
|
|
119
|
+
/** The identifier a server declares the Events extension under, in capabilities.extensions. */
|
|
120
|
+
export const EVENTS_EXTENSION = "io.modelcontextprotocol/events";
|
|
121
|
+
|
|
122
|
+
// The parts of an MCP server and of a request's context this module uses.
|
|
123
|
+
interface Served {
|
|
124
|
+
registerCapabilities(capabilities: object): void;
|
|
125
|
+
setRequestHandler(method: string, schemas: object, handler: (request: any, ctx: Context) => unknown): void;
|
|
184
126
|
}
|
|
185
|
-
|
|
186
|
-
|
|
127
|
+
interface Context {
|
|
128
|
+
mcpReq: { id: string | number; signal: AbortSignal; notify(notification: { method: string; params: object }): Promise<void> };
|
|
129
|
+
http?: SubscriptionContext & object;
|
|
130
|
+
}
|
|
131
|
+
/** An emitted event, kept at its sequence for polls and reconnecting streams. */
|
|
132
|
+
interface Kept { sequence: number; at: number; target: string | undefined; occurrence: EventOccurrence }
|
|
133
|
+
/** A subscription held by a push stream or a poll lease. */
|
|
134
|
+
interface Held {
|
|
187
135
|
readonly id: string;
|
|
188
|
-
readonly name: string;
|
|
189
|
-
readonly delivery: DeliveryModeName;
|
|
190
136
|
readonly arguments: unknown;
|
|
191
137
|
readonly context: SubscriptionContext;
|
|
192
138
|
/** Nothing is emitted to it alone: a poll lease shared by callers without an identity. */
|
|
@@ -196,58 +142,18 @@ export interface Held {
|
|
|
196
142
|
/** Says the hold is over. */
|
|
197
143
|
close(): void;
|
|
198
144
|
}
|
|
145
|
+
/** Who an emitted event is shaped for. */
|
|
146
|
+
type Reader = Pick<Held, "id" | "arguments" | "context" | "untargetable">;
|
|
199
147
|
/** The subscription a request asks for, checked. */
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
export interface EventTypeState {
|
|
148
|
+
interface Requested { id: string; lease: string; arguments: unknown; context: SubscriptionContext }
|
|
149
|
+
interface Type<A = any, D = any> {
|
|
203
150
|
readonly name: string;
|
|
204
|
-
readonly delivery: DeliveryModeName[];
|
|
205
|
-
readonly interval: number;
|
|
206
|
-
readonly check?: EventDefinition<unknown, unknown>["check"];
|
|
207
|
-
/** Whether the caller may hold the subscription, which a mode that holds subscriptions long asks again. */
|
|
208
|
-
authorize?(args: unknown, context: SubscriptionContext): boolean | Promise<boolean>;
|
|
209
|
-
/** Told each event as it is emitted: a push stream is one, and a delivery mode adds its own. */
|
|
210
|
-
readonly sinks: Set<(kept: Kept) => void>;
|
|
211
|
-
/** The subscriptions held by every mode, by id. */
|
|
212
|
-
readonly subscriptions: Map<string, { held: Set<Held>; ready: Promise<unknown> }>;
|
|
213
|
-
}
|
|
214
|
-
/** What a delivery mode works with. */
|
|
215
|
-
export interface Internals {
|
|
216
|
-
readonly types: Map<string, EventTypeState>;
|
|
217
|
-
/** Checks a request, names its event type and the subscription's id. */
|
|
218
|
-
readonly subscription: (request: Record<string, unknown>, context: SubscriptionContext, delivery?: DeliveryModeName) => Promise<{ type: EventTypeState; subscription: Requested }>;
|
|
219
|
-
/** Begins holding a subscription. */
|
|
220
|
-
readonly open: (type: EventTypeState, subscription: { id: string; delivery: DeliveryModeName; arguments: unknown; context: SubscriptionContext }, end: (error: EventError) => void) => Promise<Held>;
|
|
221
|
-
/** What a subscription is owed after a cursor. */
|
|
222
|
-
readonly read: (type: EventTypeState, subscription: Pick<Held, "id" | "arguments" | "context" | "untargetable">, cursor: string | null | undefined, options?: { maxAgeMs?: number; maxEvents?: number }) => Owed | Promise<Owed>;
|
|
223
|
-
/** What a subscription is owed of the events emitted. */
|
|
224
|
-
readonly since: (type: EventTypeState, subscription: Pick<Held, "id" | "arguments" | "context" | "untargetable">, cursor: string | null | undefined, maxAgeMs?: number, limit?: number) => Owed;
|
|
225
|
-
/** An emitted event as one subscription receives it, or nothing when it is not for it. */
|
|
226
|
-
readonly shape: (type: EventTypeState, kept: Kept, subscription: Pick<Held, "id" | "arguments" | "context" | "untargetable">, failed?: (error: EventError) => void) => EventOccurrence | undefined;
|
|
227
|
-
/** A type's current cursor. */
|
|
228
|
-
readonly position: (type: EventTypeState, sequence?: number) => string;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/** The identifier a server declares the Events extension under, in capabilities.extensions. */
|
|
232
|
-
export const EVENTS_EXTENSION = "io.modelcontextprotocol/events";
|
|
233
|
-
/** Reaches what a delivery mode needs of the events it serves. Not for applications. */
|
|
234
|
-
export const INTERNALS: unique symbol = Symbol.for("holon-sh.events.internals");
|
|
235
|
-
|
|
236
|
-
// The parts of an MCP server and of a request's context this module uses.
|
|
237
|
-
interface Served {
|
|
238
|
-
registerCapabilities(capabilities: object): void;
|
|
239
|
-
setRequestHandler(method: string, schemas: object, handler: (request: any, ctx: Context) => unknown): void;
|
|
240
|
-
}
|
|
241
|
-
interface Context {
|
|
242
|
-
mcpReq: { id: string | number; signal: AbortSignal; notify(notification: { method: string; params: object }): Promise<void> };
|
|
243
|
-
http?: SubscriptionContext & object;
|
|
244
|
-
}
|
|
245
|
-
interface Type<A = any, D = any> extends EventTypeState {
|
|
246
151
|
readonly order: number;
|
|
247
152
|
readonly description?: string | undefined;
|
|
248
153
|
readonly inputSchema: StandardSchemaWithJSON<unknown, A>;
|
|
249
154
|
readonly payloadSchema: StandardSchemaWithJSON;
|
|
250
155
|
readonly _meta?: Record<string, unknown> | undefined;
|
|
156
|
+
readonly delivery: DeliveryModeName[];
|
|
251
157
|
readonly match?: EventDefinition<A, D>["match"];
|
|
252
158
|
readonly transform?: EventDefinition<A, D>["transform"];
|
|
253
159
|
readonly authorize?: EventDefinition<A, D>["authorize"];
|
|
@@ -255,11 +161,13 @@ interface Type<A = any, D = any> extends EventTypeState {
|
|
|
255
161
|
readonly onUnsubscribe?: EventDefinition<A, D>["onUnsubscribe"];
|
|
256
162
|
sequence: number;
|
|
257
163
|
dropped: number;
|
|
258
|
-
kept:
|
|
259
|
-
/**
|
|
260
|
-
|
|
164
|
+
kept: Kept[];
|
|
165
|
+
/** Woken by each emitted event: one for each push stream. */
|
|
166
|
+
readonly sinks: Set<() => void>;
|
|
167
|
+
/** The subscriptions held by streams and poll leases, by id. */
|
|
168
|
+
readonly subscriptions: Map<string, { held: Set<Held>; ready: Promise<Subscription<A>> }>;
|
|
261
169
|
}
|
|
262
|
-
interface Lease { type: Type; opening
|
|
170
|
+
interface Lease { type: Type; opening: Promise<Held>; ended?: EventError; release?: () => void; timer?: ReturnType<typeof setTimeout> }
|
|
263
171
|
|
|
264
172
|
const INVALID_PARAMS = ProtocolErrorCode.InvalidParams;
|
|
265
173
|
const INTERNAL_ERROR = ProtocolErrorCode.InternalError;
|
|
@@ -294,7 +202,7 @@ const caller = (context: SubscriptionContext): string | null => {
|
|
|
294
202
|
};
|
|
295
203
|
const hex = (bytes: Uint8Array) => [...bytes].map(byte => byte.toString(16).padStart(2, "0")).join("");
|
|
296
204
|
// A subscription's id is the same for the same caller, event type and arguments, so it survives reconnects and poll
|
|
297
|
-
// leases
|
|
205
|
+
// leases. It is made with the global crypto, which needs no node:crypto at load.
|
|
298
206
|
const identify = async (who: string | null, name: string, args: unknown) => {
|
|
299
207
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify([who, name, canonical(args)])));
|
|
300
208
|
return `sub_${hex(new Uint8Array(digest, 0, 16))}`;
|
|
@@ -305,11 +213,11 @@ const unique = () => `sub_${hex(crypto.getRandomValues(new Uint8Array(16)))}`;
|
|
|
305
213
|
const settle = (run: () => unknown) => { Promise.resolve().then(run).catch(() => {}); };
|
|
306
214
|
const integer = (value: unknown, minimum: number) => value === undefined || Number.isInteger(value) && (value as number) >= minimum;
|
|
307
215
|
|
|
308
|
-
|
|
309
|
-
// emit buffer.
|
|
216
|
+
const OFFERED: DeliveryModeName[] = ["poll", "push"];
|
|
217
|
+
// What each type keeps of what it emits, for polls and reconnecting streams: the sketch's emit buffer.
|
|
310
218
|
const KEPT_EVENTS = 10_000;
|
|
311
219
|
const KEPT_AGE = 10 * 60_000;
|
|
312
|
-
// How long a poll is told to wait before the next
|
|
220
|
+
// How long a poll is told to wait before the next.
|
|
313
221
|
const INTERVAL = 1000;
|
|
314
222
|
// How often a stream hears from the server when nothing happens, as the sketch recommends.
|
|
315
223
|
const HEARTBEAT = 30_000;
|
|
@@ -321,18 +229,21 @@ const MAX_SUBSCRIPTIONS = 10_000;
|
|
|
321
229
|
const MAX_SUBSCRIPTIONS_PER_CALLER = 1000;
|
|
322
230
|
// How many event types events/list returns at a time.
|
|
323
231
|
const PAGE_SIZE = 100;
|
|
232
|
+
/**
|
|
233
|
+
* What a stream sends before it ends and its client resumes from its cursor. The draft leaves flow control to the
|
|
234
|
+
* transport, but the MCP SDK's HTTP transport queues whatever a stream sends without waiting for its reader:
|
|
235
|
+
* https://github.com/modelcontextprotocol/experimental-ext-triggers-events/blob/main/docs/design-sketch-proposal.md#note-on-flow-control
|
|
236
|
+
*/
|
|
237
|
+
const STREAM_BUDGET = 2 ** 20;
|
|
324
238
|
|
|
325
239
|
/**
|
|
326
240
|
* Event types a process emits and the MCP servers it creates serve, in the poll and push delivery modes of MCP Events,
|
|
327
241
|
* the draft extension of the Triggers & Events Working Group. Each type keeps the last ten thousand events it emits,
|
|
328
242
|
* for ten minutes, for polls and for streams that reconnect: those cursors last as long as the process, and after a
|
|
329
|
-
* restart a client is told that events were skipped. A
|
|
330
|
-
*
|
|
331
|
-
* the server every thirty seconds.
|
|
243
|
+
* restart a client is told that events were skipped. A poll is told to come back every second, and a stream hears
|
|
244
|
+
* from the server every thirty seconds.
|
|
332
245
|
*/
|
|
333
|
-
export function createEvents(
|
|
334
|
-
const modes = webhookTtl === undefined ? [] : [webhooks({ ...webhook, ttl: webhookTtl, heartbeat: HEARTBEAT })];
|
|
335
|
-
const offered = ["poll", "push", ...modes.map(mode => mode.name)];
|
|
246
|
+
export function createEvents(): Events {
|
|
336
247
|
// Cursors are positions in this process's memory. One from another process names nothing here. The name is made
|
|
337
248
|
// without node:crypto, which is not there when a server is evaluated for its contract.
|
|
338
249
|
const epoch = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
|
@@ -341,33 +252,23 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
|
|
|
341
252
|
let subscribed = 0;
|
|
342
253
|
const callers = new Map<string, number>();
|
|
343
254
|
|
|
344
|
-
const position = (type:
|
|
255
|
+
const position = (type: Type, sequence = type.sequence) => `${epoch}.${sequence}`;
|
|
345
256
|
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
// the events still kept starts from the oldest kept, and says that events were skipped.
|
|
357
|
-
const since: Internals["since"] = (state, subscription, cursor, maxAgeMs, limit = Infinity) => {
|
|
358
|
-
const type = state as Type;
|
|
359
|
-
const now = { events: [], start: position(type), cursor: position(type), hasMore: false };
|
|
360
|
-
if (cursor == null) return { ...now, truncated: false };
|
|
361
|
-
const [process, text] = cursor.split(".");
|
|
362
|
-
const sequence = Number(text);
|
|
363
|
-
if (process !== epoch || !Number.isInteger(sequence) || sequence > type.sequence) return { ...now, truncated: true };
|
|
364
|
-
const floor = maxAgeMs === undefined ? -Infinity : Date.now() - maxAgeMs;
|
|
365
|
-
const later = type.kept.filter(kept => kept.sequence > sequence);
|
|
257
|
+
// What a subscription is owed after a cursor, up to `until`: the events for it, in order, where delivery starts,
|
|
258
|
+
// and where it now stands. A null cursor starts where the subscription was opened, and so does one from another
|
|
259
|
+
// process, which says that events were skipped. One past the events still kept starts from the oldest kept, and
|
|
260
|
+
// says so too.
|
|
261
|
+
const since = (type: Type, subscription: Reader, cursor: string | null | undefined, { maxAgeMs, maxEvents: limit = Infinity, opened = type.sequence, until = type.sequence }: { maxAgeMs?: number; maxEvents?: number; opened?: number; until?: number } = {}) => {
|
|
262
|
+
const [process, text] = cursor?.split(".") ?? [];
|
|
263
|
+
const resumed = cursor != null && process === epoch && Number.isInteger(Number(text)) && Number(text) <= type.sequence;
|
|
264
|
+
const sequence = resumed ? Number(text) : opened;
|
|
265
|
+
const floor = maxAgeMs === undefined || !resumed ? -Infinity : Date.now() - maxAgeMs;
|
|
266
|
+
const later = type.kept.filter(kept => kept.sequence > sequence && kept.sequence <= until);
|
|
366
267
|
const recent = later.filter(kept => kept.at >= floor);
|
|
367
|
-
const truncated = sequence < type.dropped || recent.length < later.length;
|
|
268
|
+
const truncated = cursor != null && !resumed || sequence < type.dropped || recent.length < later.length;
|
|
368
269
|
const events: EventOccurrence[] = [];
|
|
369
270
|
const failures: EventError[] = [];
|
|
370
|
-
let reached = recent.length ? recent[0]!.sequence - 1 :
|
|
271
|
+
let reached = recent.length ? recent[0]!.sequence - 1 : until;
|
|
371
272
|
const start = position(type, reached);
|
|
372
273
|
for (const kept of recent) {
|
|
373
274
|
if (events.length === limit) return { events, failures, start, cursor: position(type, reached), truncated, hasMore: true };
|
|
@@ -375,82 +276,36 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
|
|
|
375
276
|
const event = shape(type, kept, subscription, error => failures.push(error));
|
|
376
277
|
if (event) events.push(event);
|
|
377
278
|
}
|
|
378
|
-
return { events, failures, start, cursor: position(type), truncated, hasMore: false };
|
|
379
|
-
};
|
|
380
|
-
|
|
381
|
-
// What a subscription is owed after a cursor from the type's check function. The cursor is the author's own. Each
|
|
382
|
-
// event is placed at its own cursor when it has one, so a batch can be cut short; otherwise at the cursor before
|
|
383
|
-
// the batch, which is safe to resume from. After a gap, delivery starts from the position the check reached.
|
|
384
|
-
const checked = async (type: Type, { arguments: args, context }: Pick<Held, "arguments" | "context">, cursor: string | null | undefined, maxAgeMs?: number, limit = Infinity): Promise<Owed> => {
|
|
385
|
-
const result = await type.check!(cursor ?? null, args, context);
|
|
386
|
-
const next = result?.cursor ?? null;
|
|
387
|
-
// A null cursor asks for the current position and nothing before it, except of a source that has no positions:
|
|
388
|
-
// each check of that is asked with null, and answers with what is new.
|
|
389
|
-
if (cursor == null && next !== null) return { events: [], start: next, cursor: next, truncated: false, hasMore: false };
|
|
390
|
-
const items = result?.events ?? [];
|
|
391
|
-
const floor = maxAgeMs === undefined || cursor == null ? -Infinity : Date.now() - maxAgeMs;
|
|
392
|
-
let truncated = next !== null && Boolean(result.truncated);
|
|
393
|
-
const events: EventOccurrence[] = [];
|
|
394
|
-
const failures: EventError[] = [];
|
|
395
|
-
let reached = cursor ?? null;
|
|
396
|
-
let cuttable = false;
|
|
397
|
-
const answer = (end: string | null, hasMore: boolean): Owed => ({ events, failures, start: truncated || !events.length ? end : cursor ?? null, cursor: end, truncated, hasMore });
|
|
398
|
-
for (const [index, item] of items.entries()) {
|
|
399
|
-
if (events.length === limit && cuttable) return answer(reached, true);
|
|
400
|
-
const after = index === items.length - 1 ? next : item.cursor ?? reached;
|
|
401
|
-
const { eventId = crypto.randomUUID(), timestamp = new Date().toISOString(), data, _meta } = item;
|
|
402
|
-
if (Date.parse(timestamp) < floor) truncated = next !== null;
|
|
403
|
-
else if (serialisable(type, eventId, { data, _meta }, error => failures.push(error))) events.push({ eventId, name: type.name, timestamp, data, ...(_meta && { _meta }), cursor: after });
|
|
404
|
-
reached = after;
|
|
405
|
-
cuttable = item.cursor != null;
|
|
406
|
-
}
|
|
407
|
-
return answer(next, Boolean(result.hasMore));
|
|
279
|
+
return { events, failures, start, cursor: position(type, until), truncated, hasMore: false };
|
|
408
280
|
};
|
|
409
281
|
|
|
410
|
-
// What a subscription is owed after a cursor, whichever way its type learns of events.
|
|
411
|
-
const read: Internals["read"] = (type, subscription, cursor, { maxAgeMs, maxEvents } = {}) =>
|
|
412
|
-
type.check ? checked(type as Type, subscription, cursor, maxAgeMs, maxEvents) : since(type, subscription, cursor, maxAgeMs, maxEvents);
|
|
413
|
-
|
|
414
282
|
// The occurrence a subscription receives, or nothing when it is not for it. An event emitted to one subscription
|
|
415
283
|
// is for that one alone, and its arguments are not asked.
|
|
416
284
|
// An event that cannot be shaped for a subscription, because `match` or `transform` throws or what `transform`
|
|
417
285
|
// returns cannot be sent as JSON, is skipped for that subscription alone, so its cursor moves past it. It is
|
|
418
286
|
// logged, and `failed` hears of it so a stream can say so once.
|
|
419
|
-
const shape
|
|
420
|
-
const type = state as Type;
|
|
287
|
+
const shape = (type: Type, { sequence, target, occurrence }: Kept, { id, untargetable, arguments: args, context }: Reader, failed: (error: EventError) => void): EventOccurrence | undefined => {
|
|
421
288
|
let data: unknown;
|
|
422
289
|
try {
|
|
423
290
|
if (target !== undefined ? untargetable || target !== id : type.match && !type.match(occurrence.data, args, context)) return undefined;
|
|
424
291
|
data = type.transform ? type.transform(occurrence.data, args, context) : occurrence.data;
|
|
425
292
|
if (type.transform) JSON.stringify(data);
|
|
426
|
-
} catch (error) {
|
|
427
|
-
|
|
293
|
+
} catch (error: any) {
|
|
294
|
+
const { eventId } = occurrence;
|
|
295
|
+
console.error(`holon-sh/ext/events: event ${eventId} of ${type.name} was skipped for a subscription:`, error);
|
|
296
|
+
failed({ code: INTERNAL_ERROR, message: `Event ${eventId} could not be delivered and was skipped`, data: { eventId, reason: String(error?.message ?? error) } });
|
|
428
297
|
return undefined;
|
|
429
298
|
}
|
|
430
|
-
return { ...occurrence, data,
|
|
431
|
-
};
|
|
432
|
-
const skipped = (type: Type, eventId: string, error: any, failed?: (error: EventError) => void) => {
|
|
433
|
-
console.error(`holon-sh/ext/events: event ${eventId} of ${type.name} was skipped for a subscription:`, error);
|
|
434
|
-
failed?.({ code: INTERNAL_ERROR, message: `Event ${eventId} could not be delivered and was skipped`, data: { eventId, reason: String(error?.message ?? error) } });
|
|
435
|
-
};
|
|
436
|
-
// What a check function found is skipped in the same way when it cannot be sent as JSON.
|
|
437
|
-
const serialisable = (type: Type, eventId: string, value: unknown, failed: (error: EventError) => void) => {
|
|
438
|
-
try {
|
|
439
|
-
JSON.stringify(value);
|
|
440
|
-
return true;
|
|
441
|
-
} catch (error) {
|
|
442
|
-
skipped(type, eventId, error, failed);
|
|
443
|
-
return false;
|
|
444
|
-
}
|
|
299
|
+
return { ...occurrence, data, cursor: position(type, sequence) };
|
|
445
300
|
};
|
|
446
301
|
|
|
447
302
|
// The event type and arguments a request names, checked as the sketch's errors require, in its order: the type,
|
|
448
303
|
// the delivery mode, the request's own fields, the arguments, and then whether the caller may subscribe. A caller
|
|
449
304
|
// refused loses what it holds of that subscription at once.
|
|
450
|
-
const subscription
|
|
305
|
+
const subscription = async ({ name, arguments: args = {}, cursor, maxAgeMs, maxEvents }: Record<string, unknown>, context: SubscriptionContext, delivery: DeliveryModeName): Promise<{ type: Type; subscription: Requested }> => {
|
|
451
306
|
const type = types.get(name as string);
|
|
452
307
|
if (!type) throw refusal(removed);
|
|
453
|
-
if (
|
|
308
|
+
if (!type.delivery.includes(delivery)) throw refusal({ code: UNSUPPORTED, message: "Unsupported", data: { feature: "deliveryMode", value: delivery } });
|
|
454
309
|
if (cursor != null && typeof cursor !== "string") throw new ProtocolError(INVALID_PARAMS, "cursor must be a string or null");
|
|
455
310
|
if (!integer(maxAgeMs, 0)) throw new ProtocolError(INVALID_PARAMS, "maxAgeMs must be a non-negative integer");
|
|
456
311
|
if (!integer(maxEvents, 1)) throw new ProtocolError(INVALID_PARAMS, "maxEvents must be a positive integer");
|
|
@@ -468,16 +323,15 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
|
|
|
468
323
|
return { type, subscription: { id: who === null ? unique() : lease, lease, arguments: checked.value, context } };
|
|
469
324
|
};
|
|
470
325
|
|
|
471
|
-
const endSubscription = (type:
|
|
326
|
+
const endSubscription = (type: Type, id: string, error: EventError) => {
|
|
472
327
|
for (const held of [...type.subscriptions.get(id)?.held ?? []]) held.end(error);
|
|
473
328
|
};
|
|
474
329
|
|
|
475
|
-
// Begins holding a subscription, by a stream
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
const open
|
|
480
|
-
const type = state as Type;
|
|
330
|
+
// Begins holding a subscription, by a stream or a poll lease: counted against the limits first, so a refused one
|
|
331
|
+
// provisions nothing. The type's onSubscribe is called when the first hold of an id begins, and its onUnsubscribe
|
|
332
|
+
// when the last ends, so two streams of one subscription set it up once. `end` is how the holder is told the app
|
|
333
|
+
// terminated it; `close` is how the holder says its hold is over.
|
|
334
|
+
const open = async (type: Type, { id, delivery, arguments: args, context }: { id: string; delivery: DeliveryModeName; arguments: unknown; context: SubscriptionContext }, end: (error: EventError) => void): Promise<Held> => {
|
|
481
335
|
if (types.get(type.name) !== type) throw refusal(removed);
|
|
482
336
|
// Callers without an identity cannot be told apart, so they count together as one.
|
|
483
337
|
const who = caller(context) ?? "anonymous";
|
|
@@ -495,7 +349,7 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
|
|
|
495
349
|
}
|
|
496
350
|
const current = entry;
|
|
497
351
|
let closed = false;
|
|
498
|
-
const held: Held = { id,
|
|
352
|
+
const held: Held = { id, arguments: args, context, end, close() {
|
|
499
353
|
if (closed) return;
|
|
500
354
|
closed = true;
|
|
501
355
|
subscribed--;
|
|
@@ -506,7 +360,7 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
|
|
|
506
360
|
if (current.held.size) return;
|
|
507
361
|
if (type.subscriptions.get(id) === current) type.subscriptions.delete(id);
|
|
508
362
|
// Only a subscription that was set up is told it ended.
|
|
509
|
-
current.ready.then(subscription => { if (type.onUnsubscribe) settle(() => type.onUnsubscribe!(subscription
|
|
363
|
+
current.ready.then(subscription => { if (type.onUnsubscribe) settle(() => type.onUnsubscribe!(subscription)); }, () => {});
|
|
510
364
|
} };
|
|
511
365
|
current.held.add(held);
|
|
512
366
|
try {
|
|
@@ -539,13 +393,12 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
|
|
|
539
393
|
lent = undefined;
|
|
540
394
|
}
|
|
541
395
|
if (!lent) {
|
|
542
|
-
const fresh: Lease = { type }
|
|
543
|
-
lent = fresh;
|
|
544
|
-
leases.set(key, fresh);
|
|
545
|
-
fresh.opening = open(type, { ...subscribed, id: key, delivery: "poll" }, error => {
|
|
396
|
+
const fresh: Lease = { type, opening: open(type, { ...subscribed, id: key, delivery: "poll" }, error => {
|
|
546
397
|
fresh.ended = error;
|
|
547
398
|
fresh.release?.();
|
|
548
|
-
});
|
|
399
|
+
}) };
|
|
400
|
+
lent = fresh;
|
|
401
|
+
leases.set(key, fresh);
|
|
549
402
|
fresh.opening.then(held => {
|
|
550
403
|
if (shared) held.untargetable = true;
|
|
551
404
|
fresh.release = () => held.close();
|
|
@@ -553,7 +406,7 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
|
|
|
553
406
|
}, () => { if (leases.get(key) === fresh) leases.delete(key); });
|
|
554
407
|
}
|
|
555
408
|
const current = lent;
|
|
556
|
-
const held = await current.opening
|
|
409
|
+
const held = await current.opening;
|
|
557
410
|
// Ended while it was being set up.
|
|
558
411
|
if (current.ended) {
|
|
559
412
|
leases.delete(key);
|
|
@@ -568,28 +421,18 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
|
|
|
568
421
|
return held;
|
|
569
422
|
};
|
|
570
423
|
|
|
571
|
-
// What a delivery mode works with: `subscription` checks a request, names its event type and the subscription's
|
|
572
|
-
// id, `open` begins holding a subscription, `read` gives a subscription what it is owed after a cursor (`since` and
|
|
573
|
-
// `shape` for emitted events alone), `position` is a type's cursor, a type's `sinks` hear what is emitted, and its
|
|
574
|
-
// `subscriptions` are the ones held by every mode, by id.
|
|
575
|
-
const internals: Internals = { types, subscription, open, read, since, shape, position };
|
|
576
|
-
|
|
577
424
|
return {
|
|
578
|
-
[INTERNALS]: internals,
|
|
579
425
|
/**
|
|
580
426
|
* Declares an event type and returns the function that emits it. `match` decides which subscriptions receive an
|
|
581
|
-
* event from their arguments, and `transform` shapes its data for each.
|
|
582
|
-
* cursor, for event types whose source keeps its own history.
|
|
427
|
+
* event from their arguments, and `transform` shapes its data for each.
|
|
583
428
|
*/
|
|
584
|
-
event<A, D>(name: string, { description, inputSchema, payloadSchema, _meta, match, transform,
|
|
429
|
+
event<A, D>(name: string, { description, inputSchema, payloadSchema, _meta, match, transform, delivery = OFFERED, authorize, onSubscribe, onUnsubscribe }: EventDefinition<A, D>): Emitter<D> {
|
|
585
430
|
if (types.has(name)) throw new Error(`Event ${name} is already declared.`);
|
|
586
|
-
const unknown = delivery.filter(mode => !
|
|
587
|
-
if (unknown.length || !delivery.length) throw new Error(`Event ${name} is delivered by ${delivery.join(", ") || "nothing"}, but only ${
|
|
588
|
-
// Webhook delivery sends what is emitted, and does not ask a check function.
|
|
589
|
-
if (check && delivery.includes("webhook")) throw new Error(`Event ${name} has a check function, which webhook delivery does not read.`);
|
|
431
|
+
const unknown = delivery.filter(mode => !OFFERED.includes(mode));
|
|
432
|
+
if (unknown.length || !delivery.length) throw new Error(`Event ${name} is delivered by ${delivery.join(", ") || "nothing"}, but only ${OFFERED.join(", ")} are served.`);
|
|
590
433
|
const type: Type<A, D> = {
|
|
591
|
-
name, order: ++declared, description, inputSchema, payloadSchema, _meta, match, transform,
|
|
592
|
-
sequence: 0, dropped: 0, kept: [], sinks: new Set(), subscriptions: new Map(),
|
|
434
|
+
name, order: ++declared, description, inputSchema, payloadSchema, _meta, delivery, match, transform, authorize, onSubscribe, onUnsubscribe,
|
|
435
|
+
sequence: 0, dropped: 0, kept: [], sinks: new Set(), subscriptions: new Map(),
|
|
593
436
|
};
|
|
594
437
|
types.set(name, type);
|
|
595
438
|
return {
|
|
@@ -597,21 +440,13 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
|
|
|
597
440
|
const occurrence = { eventId, name, timestamp, data };
|
|
598
441
|
// Data that cannot be sent as JSON is refused here, before anything is kept or sent.
|
|
599
442
|
JSON.stringify(occurrence);
|
|
600
|
-
// A type with a check function is asked for what polls are owed, so what it emits is pushed and not kept.
|
|
601
|
-
if (type.check) {
|
|
602
|
-
// An event for one subscription that has no stream to take it is lost.
|
|
603
|
-
if (target !== undefined && ![...type.subscriptions.get(target)?.held ?? []].some(held => held.delivery !== "poll")) owe(type, target);
|
|
604
|
-
for (const sink of type.sinks) sink({ target, occurrence });
|
|
605
|
-
return;
|
|
606
|
-
}
|
|
607
443
|
// An event for one subscription is kept like any other, so the subscription has it when it comes back.
|
|
608
444
|
const kept = { sequence: ++type.sequence, at: Date.now(), target, occurrence };
|
|
609
445
|
type.kept.push(kept);
|
|
610
446
|
while (type.kept.length > KEPT_EVENTS || type.kept[0]!.at < kept.at - KEPT_AGE) type.dropped = type.kept.shift()!.sequence;
|
|
611
|
-
for (const sink of type.sinks) sink(
|
|
447
|
+
for (const sink of type.sinks) sink();
|
|
612
448
|
},
|
|
613
449
|
cursor() {
|
|
614
|
-
if (type.check) throw new Error(`Event ${name} has a check function, whose cursor is its source's own.`);
|
|
615
450
|
return position(type);
|
|
616
451
|
},
|
|
617
452
|
terminate(id, error = forbidden) {
|
|
@@ -628,9 +463,8 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
|
|
|
628
463
|
|
|
629
464
|
/** Serves the declared event types on an MCP server: events/list, events/poll and events/stream. */
|
|
630
465
|
serve(mcp) {
|
|
631
|
-
const underlying: Server = "server" in mcp ? mcp.server : mcp;
|
|
632
466
|
// The custom methods are registered through the parts of the server this module uses.
|
|
633
|
-
const server =
|
|
467
|
+
const server = ("server" in mcp ? mcp.server : mcp) as unknown as Served;
|
|
634
468
|
// The sketch declares capabilities.events. The MCP SDK's client drops top-level capabilities it does not know,
|
|
635
469
|
// so the extension is declared under capabilities.extensions too, where SDK clients read it.
|
|
636
470
|
// Clients are never told the event types changed: over Holon's stateless HTTP, the notification reaches none.
|
|
@@ -658,33 +492,43 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
|
|
|
658
492
|
...(start + PAGE_SIZE < all.length && { nextCursor: String(page.at(-1)!.order) }),
|
|
659
493
|
};
|
|
660
494
|
});
|
|
661
|
-
for (const mode of modes) mode.serve(underlying, internals);
|
|
662
495
|
|
|
663
496
|
server.setRequestHandler("events/poll", { params }, async (request, ctx) => {
|
|
664
497
|
const { type, subscription: subscribed } = await subscription(request, ctx.http, "poll");
|
|
665
|
-
|
|
498
|
+
// Taken before the lease, which waits for onSubscribe. The sketch's poll from null returns no events and a
|
|
499
|
+
// fresh cursor: this one is where the subscription opened, so the next poll has what onSubscribe emitted.
|
|
500
|
+
const opened = type.sequence;
|
|
501
|
+
const held = await lease(type, subscribed);
|
|
666
502
|
// Each poll is answered for its own caller, with the lease's id.
|
|
667
|
-
const { events, start, failures,
|
|
668
|
-
const lost = !held.untargetable && (type as Type).owed.delete(held.id);
|
|
503
|
+
const { events, start, failures, ...rest } = since(type, { ...subscribed, id: held.id, untargetable: held.untargetable }, request.cursor, { maxAgeMs: request.maxAgeMs, maxEvents: request.maxEvents, opened, ...(request.cursor == null && { until: opened }) });
|
|
669
504
|
// A poll carries its cursor once, for the whole response.
|
|
670
|
-
return { events: events.map(({ cursor, ...event }) => event), ...rest,
|
|
505
|
+
return { events: events.map(({ cursor, ...event }) => event), ...rest, nextPollMs: INTERVAL };
|
|
671
506
|
});
|
|
672
507
|
|
|
673
508
|
server.setRequestHandler("events/stream", { params }, async (request, ctx) => {
|
|
674
|
-
const { type
|
|
675
|
-
const type = state as Type;
|
|
509
|
+
const { type, subscription: subscribed } = await subscription(request, ctx.http, "push");
|
|
676
510
|
const { signal } = ctx.mcpReq;
|
|
677
511
|
const meta = { [SUBSCRIPTION_ID_META_KEY]: ctx.mcpReq.id };
|
|
678
512
|
let done = false;
|
|
679
513
|
let ending: EventError | undefined;
|
|
514
|
+
// Set when the app terminates the stream: the last event emitted before, which the stream still sends.
|
|
515
|
+
let until: number | undefined;
|
|
680
516
|
let wake = () => {};
|
|
681
517
|
const finish = () => {
|
|
682
518
|
done = true;
|
|
683
519
|
wake();
|
|
684
520
|
};
|
|
521
|
+
// What is emitted from here on is the stream's, so it has what onSubscribe emits to it.
|
|
522
|
+
const opened = type.sequence;
|
|
685
523
|
const held = await open(type, { ...subscribed, delivery: "push" }, error => {
|
|
524
|
+
// Lost access ends delivery at once, even while what came before is still being sent. Otherwise only the
|
|
525
|
+
// first end counts, and the stream first sends what was emitted before it.
|
|
526
|
+
const lost = error.code === FORBIDDEN;
|
|
527
|
+
if (ending && !lost) return;
|
|
686
528
|
ending = error;
|
|
687
|
-
finish();
|
|
529
|
+
if (lost) return finish();
|
|
530
|
+
until = type.sequence;
|
|
531
|
+
wake();
|
|
688
532
|
});
|
|
689
533
|
// A client that left while the subscription was being set up is not waited for.
|
|
690
534
|
if (signal.aborted) {
|
|
@@ -698,27 +542,26 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
|
|
|
698
542
|
}
|
|
699
543
|
signal.addEventListener("abort", finish, { once: true });
|
|
700
544
|
// A stream that cannot be written to is over.
|
|
545
|
+
let sent = 0;
|
|
701
546
|
const notify = async (method: string, body: object) => {
|
|
702
547
|
if (signal.aborted) return;
|
|
548
|
+
const params = { ...body, _meta: meta };
|
|
703
549
|
try {
|
|
704
|
-
await ctx.mcpReq.notify({ method: `notifications/events/${method}`, params
|
|
550
|
+
await ctx.mcpReq.notify({ method: `notifications/events/${method}`, params });
|
|
705
551
|
} catch {
|
|
706
552
|
finish();
|
|
707
553
|
}
|
|
554
|
+
if ((sent += JSON.stringify(params).length) > STREAM_BUDGET) finish();
|
|
708
555
|
};
|
|
709
|
-
// The stream follows its cursor
|
|
710
|
-
//
|
|
711
|
-
//
|
|
556
|
+
// The stream follows its cursor, and emitted events wake it. What it is owed waits in the buffer, not here, so
|
|
557
|
+
// a slow client is not owed more memory than one batch; when what it is owed is no longer kept, it is told of
|
|
558
|
+
// the gap.
|
|
712
559
|
let cursor: string | null = request.cursor ?? null;
|
|
713
560
|
let behind = true;
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
if (type.check) {
|
|
719
|
-
pushed.push(kept);
|
|
720
|
-
if (pushed.length > KEPT_EVENTS) pushed.shift();
|
|
721
|
-
} else behind = true;
|
|
561
|
+
const sink = () => {
|
|
562
|
+
// Nothing emitted after the app terminated the stream is for it.
|
|
563
|
+
if (until !== undefined) return;
|
|
564
|
+
behind = true;
|
|
722
565
|
wake();
|
|
723
566
|
};
|
|
724
567
|
type.sinks.add(sink);
|
|
@@ -739,58 +582,29 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
|
|
|
739
582
|
}, HEARTBEAT);
|
|
740
583
|
let first = true;
|
|
741
584
|
try {
|
|
742
|
-
let due = 0;
|
|
743
585
|
while (!done) {
|
|
744
|
-
if (
|
|
586
|
+
if (behind) {
|
|
745
587
|
behind = false;
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
}
|
|
754
|
-
due = Date.now() + type.interval;
|
|
755
|
-
if (result) {
|
|
756
|
-
// A check function's gap is told after the events that follow it, at the position after them, since
|
|
757
|
-
// the position before them is not known and the client's cursor must not go back. The stream is
|
|
758
|
-
// confirmed before anything else is said on it.
|
|
759
|
-
const after = type.check && result.truncated && !first;
|
|
760
|
-
const truncated = result.truncated || first && type.owed.delete(held.id);
|
|
761
|
-
if (first || truncated && !after) await notify("active", { cursor: type.check && result.truncated ? cursor : result.start, truncated });
|
|
762
|
-
if (first) beat = heartbeats();
|
|
763
|
-
first = false;
|
|
764
|
-
for (const error of result.failures ?? []) await notify("error", { error });
|
|
765
|
-
for (const event of result.events) {
|
|
766
|
-
if (done) break;
|
|
767
|
-
await notify("event", event);
|
|
768
|
-
cursor = event.cursor ?? null;
|
|
769
|
-
}
|
|
588
|
+
const result = since(type, held, cursor, { ...(first && request.maxAgeMs !== undefined && { maxAgeMs: request.maxAgeMs }), opened, until });
|
|
589
|
+
// The stream is confirmed before anything else is said on it.
|
|
590
|
+
if (first || result.truncated) await notify("active", { cursor: result.start, truncated: result.truncated });
|
|
591
|
+
if (first) beat = heartbeats();
|
|
592
|
+
first = false;
|
|
593
|
+
for (const error of result.failures) await notify("error", { error });
|
|
594
|
+
for (const event of result.events) {
|
|
770
595
|
if (done) break;
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
if (result.hasMore) {
|
|
774
|
-
due = 0;
|
|
775
|
-
behind = true;
|
|
776
|
-
}
|
|
596
|
+
await notify("event", event);
|
|
597
|
+
cursor = event.cursor ?? null;
|
|
777
598
|
}
|
|
599
|
+
if (done) break;
|
|
600
|
+
cursor = result.cursor;
|
|
778
601
|
}
|
|
779
|
-
//
|
|
780
|
-
|
|
781
|
-
let failure: EventError | undefined;
|
|
782
|
-
const event = shape(type, pushed.shift()!, held, error => { failure = error; });
|
|
783
|
-
if (failure) await notify("error", { error: failure });
|
|
784
|
-
if (event) await notify("event", { ...event, cursor });
|
|
785
|
-
}
|
|
786
|
-
if (done) break;
|
|
787
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
602
|
+
// Terminated by the app, the stream ends once it has sent what was emitted before.
|
|
603
|
+
if (done || until !== undefined && !behind) break;
|
|
788
604
|
await new Promise<void>(resolve => {
|
|
789
605
|
wake = resolve;
|
|
790
|
-
if (done ||
|
|
791
|
-
else if (type.check) timer = setTimeout(resolve, Math.max(0, due - Date.now()));
|
|
606
|
+
if (done || behind) resolve();
|
|
792
607
|
});
|
|
793
|
-
clearTimeout(timer);
|
|
794
608
|
wake = () => {};
|
|
795
609
|
}
|
|
796
610
|
if (ending) {
|
|
@@ -800,8 +614,6 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
|
|
|
800
614
|
} finally {
|
|
801
615
|
clearInterval(beat);
|
|
802
616
|
type.sinks.delete(sink);
|
|
803
|
-
// What was emitted to this subscription alone and not sent is owed to it as a gap when it comes back.
|
|
804
|
-
if (held.id === subscribed.lease && pushed.some(kept => kept.target === held.id)) owe(type, held.id);
|
|
805
617
|
held.close();
|
|
806
618
|
}
|
|
807
619
|
// The final frame, sent when the server ends the stream; when the client did, nobody is listening.
|