holon-sh 0.0.1-dev.12 → 0.0.1-dev.14
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 +33 -154
- package/dist/ext/events.d.ts.map +1 -1
- package/dist/ext/events.js +146 -224
- 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 +17 -12
- package/dist/runtime.js.map +1 -1
- package/package.json +1 -2
- package/src/ext/events.ts +201 -331
- package/src/ext/files.client.ts +2 -2
- package/src/protocol.ts +1 -1
- package/src/runtime.ts +17 -13
- 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/dist/ext/events.js
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
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
|
-
import { webhooks } from "./webhooks.js";
|
|
9
6
|
/** The identifier a server declares the Events extension under, in capabilities.extensions. */
|
|
10
7
|
export const EVENTS_EXTENSION = "io.modelcontextprotocol/events";
|
|
11
|
-
/** Reaches what a delivery mode needs of the events it serves. Not for applications. */
|
|
12
|
-
export const INTERNALS = Symbol.for("holon-sh.events.internals");
|
|
13
8
|
const INVALID_PARAMS = ProtocolErrorCode.InvalidParams;
|
|
14
9
|
const INTERNAL_ERROR = ProtocolErrorCode.InternalError;
|
|
15
10
|
// The sketch's general-purpose codes, provisional until the specification allocates them.
|
|
@@ -43,21 +38,31 @@ const caller = (context) => {
|
|
|
43
38
|
};
|
|
44
39
|
const hex = (bytes) => [...bytes].map(byte => byte.toString(16).padStart(2, "0")).join("");
|
|
45
40
|
// A subscription's id is the same for the same caller, event type and arguments, so it survives reconnects and poll
|
|
46
|
-
// leases
|
|
41
|
+
// leases. It is made with the global crypto, which needs no node:crypto at load.
|
|
47
42
|
const identify = async (who, name, args) => {
|
|
48
43
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify([who, name, canonical(args)])));
|
|
49
44
|
return `sub_${hex(new Uint8Array(digest, 0, 16))}`;
|
|
50
45
|
};
|
|
51
46
|
// A caller without an identity gets an id of its own for each stream.
|
|
52
47
|
const unique = () => `sub_${hex(crypto.getRandomValues(new Uint8Array(16)))}`;
|
|
48
|
+
// The subscriber a subscription's arguments name, whoever the caller.
|
|
49
|
+
const named = (args) => JSON.stringify(canonical(args));
|
|
50
|
+
// Counts up or down under a key, and forgets a key that reaches none.
|
|
51
|
+
const count = (counts, key, by) => {
|
|
52
|
+
const total = (counts.get(key) ?? 0) + by;
|
|
53
|
+
if (total)
|
|
54
|
+
counts.set(key, total);
|
|
55
|
+
else
|
|
56
|
+
counts.delete(key);
|
|
57
|
+
};
|
|
53
58
|
// A teardown that fails has nobody to tell, and must not take the process down.
|
|
54
59
|
const settle = (run) => { Promise.resolve().then(run).catch(() => { }); };
|
|
55
60
|
const integer = (value, minimum) => value === undefined || Number.isInteger(value) && value >= minimum;
|
|
56
|
-
|
|
57
|
-
// emit buffer.
|
|
61
|
+
const OFFERED = ["poll", "push"];
|
|
62
|
+
// What each type keeps of what it emits, for polls and reconnecting streams: the sketch's emit buffer.
|
|
58
63
|
const KEPT_EVENTS = 10_000;
|
|
59
64
|
const KEPT_AGE = 10 * 60_000;
|
|
60
|
-
// How long a poll is told to wait before the next
|
|
65
|
+
// How long a poll is told to wait before the next.
|
|
61
66
|
const INTERVAL = 1000;
|
|
62
67
|
// How often a stream hears from the server when nothing happens, as the sketch recommends.
|
|
63
68
|
const HEARTBEAT = 30_000;
|
|
@@ -79,13 +84,10 @@ const STREAM_BUDGET = 2 ** 20;
|
|
|
79
84
|
* Event types a process emits and the MCP servers it creates serve, in the poll and push delivery modes of MCP Events,
|
|
80
85
|
* the draft extension of the Triggers & Events Working Group. Each type keeps the last ten thousand events it emits,
|
|
81
86
|
* for ten minutes, for polls and for streams that reconnect: those cursors last as long as the process, and after a
|
|
82
|
-
* restart a client is told that events were skipped. A
|
|
83
|
-
*
|
|
84
|
-
* the server every thirty seconds.
|
|
87
|
+
* restart a client is told that events were skipped. A poll is told to come back every second, and a stream hears
|
|
88
|
+
* from the server every thirty seconds.
|
|
85
89
|
*/
|
|
86
|
-
export function createEvents(
|
|
87
|
-
const modes = webhookTtl === undefined ? [] : [webhooks({ ...webhook, ttl: webhookTtl, heartbeat: HEARTBEAT })];
|
|
88
|
-
const offered = ["poll", "push", ...modes.map(mode => mode.name)];
|
|
90
|
+
export function createEvents() {
|
|
89
91
|
// Cursors are positions in this process's memory. One from another process names nothing here. The name is made
|
|
90
92
|
// without node:crypto, which is not there when a server is evaluated for its contract.
|
|
91
93
|
const epoch = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
|
@@ -94,33 +96,21 @@ export function createEvents({ webhookTtl, webhook } = {}) {
|
|
|
94
96
|
let subscribed = 0;
|
|
95
97
|
const callers = new Map();
|
|
96
98
|
const position = (type, sequence = type.sequence) => `${epoch}.${sequence}`;
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
// the events still kept starts from the oldest kept, and says that events were skipped.
|
|
108
|
-
const since = (state, subscription, cursor, maxAgeMs, limit = Infinity) => {
|
|
109
|
-
const type = state;
|
|
110
|
-
const now = { events: [], start: position(type), cursor: position(type), hasMore: false };
|
|
111
|
-
if (cursor == null)
|
|
112
|
-
return { ...now, truncated: false };
|
|
113
|
-
const [process, text] = cursor.split(".");
|
|
114
|
-
const sequence = Number(text);
|
|
115
|
-
if (process !== epoch || !Number.isInteger(sequence) || sequence > type.sequence)
|
|
116
|
-
return { ...now, truncated: true };
|
|
117
|
-
const floor = maxAgeMs === undefined ? -Infinity : Date.now() - maxAgeMs;
|
|
118
|
-
const later = type.kept.filter(kept => kept.sequence > sequence);
|
|
99
|
+
// What a subscription is owed after a cursor, up to `until`: the events for it, in order, where delivery starts,
|
|
100
|
+
// and where it now stands. A null cursor starts where the subscription was opened, and so does one from another
|
|
101
|
+
// process, which says that events were skipped. One past the events still kept starts from the oldest kept, and
|
|
102
|
+
// says so too.
|
|
103
|
+
const since = (type, subscription, cursor, { maxAgeMs, maxEvents: limit = Infinity, opened = type.sequence, until = type.sequence } = {}) => {
|
|
104
|
+
const [process, text] = cursor?.split(".") ?? [];
|
|
105
|
+
const resumed = cursor != null && process === epoch && Number.isInteger(Number(text)) && Number(text) <= type.sequence;
|
|
106
|
+
const sequence = resumed ? Number(text) : opened;
|
|
107
|
+
const floor = maxAgeMs === undefined || !resumed ? -Infinity : Date.now() - maxAgeMs;
|
|
108
|
+
const later = type.kept.filter(kept => kept.sequence > sequence && kept.sequence <= until);
|
|
119
109
|
const recent = later.filter(kept => kept.at >= floor);
|
|
120
|
-
const truncated = sequence < type.dropped || recent.length < later.length;
|
|
110
|
+
const truncated = cursor != null && !resumed || sequence < type.dropped || recent.length < later.length;
|
|
121
111
|
const events = [];
|
|
122
112
|
const failures = [];
|
|
123
|
-
let reached = recent.length ? recent[0].sequence - 1 :
|
|
113
|
+
let reached = recent.length ? recent[0].sequence - 1 : until;
|
|
124
114
|
const start = position(type, reached);
|
|
125
115
|
for (const kept of recent) {
|
|
126
116
|
if (events.length === limit)
|
|
@@ -130,49 +120,14 @@ export function createEvents({ webhookTtl, webhook } = {}) {
|
|
|
130
120
|
if (event)
|
|
131
121
|
events.push(event);
|
|
132
122
|
}
|
|
133
|
-
return { events, failures, start, cursor: position(type), truncated, hasMore: false };
|
|
134
|
-
};
|
|
135
|
-
// What a subscription is owed after a cursor from the type's check function. The cursor is the author's own. Each
|
|
136
|
-
// event is placed at its own cursor when it has one, so a batch can be cut short; otherwise at the cursor before
|
|
137
|
-
// the batch, which is safe to resume from. After a gap, delivery starts from the position the check reached.
|
|
138
|
-
const checked = async (type, { arguments: args, context }, cursor, maxAgeMs, limit = Infinity) => {
|
|
139
|
-
const result = await type.check(cursor ?? null, args, context);
|
|
140
|
-
const next = result?.cursor ?? null;
|
|
141
|
-
// A null cursor asks for the current position and nothing before it, except of a source that has no positions:
|
|
142
|
-
// each check of that is asked with null, and answers with what is new.
|
|
143
|
-
if (cursor == null && next !== null)
|
|
144
|
-
return { events: [], start: next, cursor: next, truncated: false, hasMore: false };
|
|
145
|
-
const items = result?.events ?? [];
|
|
146
|
-
const floor = maxAgeMs === undefined || cursor == null ? -Infinity : Date.now() - maxAgeMs;
|
|
147
|
-
let truncated = next !== null && Boolean(result.truncated);
|
|
148
|
-
const events = [];
|
|
149
|
-
const failures = [];
|
|
150
|
-
let reached = cursor ?? null;
|
|
151
|
-
let cuttable = false;
|
|
152
|
-
const answer = (end, hasMore) => ({ events, failures, start: truncated || !events.length ? end : cursor ?? null, cursor: end, truncated, hasMore });
|
|
153
|
-
for (const [index, item] of items.entries()) {
|
|
154
|
-
if (events.length === limit && cuttable)
|
|
155
|
-
return answer(reached, true);
|
|
156
|
-
const after = index === items.length - 1 ? next : item.cursor ?? reached;
|
|
157
|
-
const { eventId = crypto.randomUUID(), timestamp = new Date().toISOString(), data, _meta } = item;
|
|
158
|
-
if (Date.parse(timestamp) < floor)
|
|
159
|
-
truncated = next !== null;
|
|
160
|
-
else if (serialisable(type, eventId, { data, _meta }, error => failures.push(error)))
|
|
161
|
-
events.push({ eventId, name: type.name, timestamp, data, ...(_meta && { _meta }), cursor: after });
|
|
162
|
-
reached = after;
|
|
163
|
-
cuttable = item.cursor != null;
|
|
164
|
-
}
|
|
165
|
-
return answer(next, Boolean(result.hasMore));
|
|
123
|
+
return { events, failures, start, cursor: position(type, until), truncated, hasMore: false };
|
|
166
124
|
};
|
|
167
|
-
// What a subscription is owed after a cursor, whichever way its type learns of events.
|
|
168
|
-
const read = (type, subscription, cursor, { maxAgeMs, maxEvents } = {}) => type.check ? checked(type, subscription, cursor, maxAgeMs, maxEvents) : since(type, subscription, cursor, maxAgeMs, maxEvents);
|
|
169
125
|
// The occurrence a subscription receives, or nothing when it is not for it. An event emitted to one subscription
|
|
170
126
|
// is for that one alone, and its arguments are not asked.
|
|
171
127
|
// An event that cannot be shaped for a subscription, because `match` or `transform` throws or what `transform`
|
|
172
128
|
// returns cannot be sent as JSON, is skipped for that subscription alone, so its cursor moves past it. It is
|
|
173
129
|
// logged, and `failed` hears of it so a stream can say so once.
|
|
174
|
-
const shape = (
|
|
175
|
-
const type = state;
|
|
130
|
+
const shape = (type, { sequence, target, occurrence }, { id, untargetable, arguments: args, context }, failed) => {
|
|
176
131
|
let data;
|
|
177
132
|
try {
|
|
178
133
|
if (target !== undefined ? untargetable || target !== id : type.match && !type.match(occurrence.data, args, context))
|
|
@@ -182,25 +137,12 @@ export function createEvents({ webhookTtl, webhook } = {}) {
|
|
|
182
137
|
JSON.stringify(data);
|
|
183
138
|
}
|
|
184
139
|
catch (error) {
|
|
185
|
-
|
|
140
|
+
const { eventId } = occurrence;
|
|
141
|
+
console.error(`holon-sh/ext/events: event ${eventId} of ${type.name} was skipped for a subscription:`, error);
|
|
142
|
+
failed({ code: INTERNAL_ERROR, message: `Event ${eventId} could not be delivered and was skipped`, data: { eventId, reason: String(error?.message ?? error) } });
|
|
186
143
|
return undefined;
|
|
187
144
|
}
|
|
188
|
-
return { ...occurrence, data,
|
|
189
|
-
};
|
|
190
|
-
const skipped = (type, eventId, error, failed) => {
|
|
191
|
-
console.error(`holon-sh/ext/events: event ${eventId} of ${type.name} was skipped for a subscription:`, error);
|
|
192
|
-
failed?.({ code: INTERNAL_ERROR, message: `Event ${eventId} could not be delivered and was skipped`, data: { eventId, reason: String(error?.message ?? error) } });
|
|
193
|
-
};
|
|
194
|
-
// What a check function found is skipped in the same way when it cannot be sent as JSON.
|
|
195
|
-
const serialisable = (type, eventId, value, failed) => {
|
|
196
|
-
try {
|
|
197
|
-
JSON.stringify(value);
|
|
198
|
-
return true;
|
|
199
|
-
}
|
|
200
|
-
catch (error) {
|
|
201
|
-
skipped(type, eventId, error, failed);
|
|
202
|
-
return false;
|
|
203
|
-
}
|
|
145
|
+
return { ...occurrence, data, cursor: position(type, sequence) };
|
|
204
146
|
};
|
|
205
147
|
// The event type and arguments a request names, checked as the sketch's errors require, in its order: the type,
|
|
206
148
|
// the delivery mode, the request's own fields, the arguments, and then whether the caller may subscribe. A caller
|
|
@@ -209,7 +151,7 @@ export function createEvents({ webhookTtl, webhook } = {}) {
|
|
|
209
151
|
const type = types.get(name);
|
|
210
152
|
if (!type)
|
|
211
153
|
throw refusal(removed);
|
|
212
|
-
if (
|
|
154
|
+
if (!type.delivery.includes(delivery))
|
|
213
155
|
throw refusal({ code: UNSUPPORTED, message: "Unsupported", data: { feature: "deliveryMode", value: delivery } });
|
|
214
156
|
if (cursor != null && typeof cursor !== "string")
|
|
215
157
|
throw new ProtocolError(INVALID_PARAMS, "cursor must be a string or null");
|
|
@@ -235,12 +177,11 @@ export function createEvents({ webhookTtl, webhook } = {}) {
|
|
|
235
177
|
for (const held of [...type.subscriptions.get(id)?.held ?? []])
|
|
236
178
|
held.end(error);
|
|
237
179
|
};
|
|
238
|
-
// Begins holding a subscription, by a stream
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
const open = async (
|
|
243
|
-
const type = state;
|
|
180
|
+
// Begins holding a subscription, by a stream or a poll lease: counted against the limits first, so a refused one
|
|
181
|
+
// provisions nothing. The type's onSubscribe is called when the first hold of an id begins, and its onUnsubscribe
|
|
182
|
+
// when the last ends, so two streams of one subscription set it up once. `end` is how the holder is told the app
|
|
183
|
+
// terminated it; `close` is how the holder says its hold is over.
|
|
184
|
+
const open = async (type, { id, delivery, arguments: args, context }, end) => {
|
|
244
185
|
if (types.get(type.name) !== type)
|
|
245
186
|
throw refusal(removed);
|
|
246
187
|
// Callers without an identity cannot be told apart, so they count together as one.
|
|
@@ -250,7 +191,15 @@ export function createEvents({ webhookTtl, webhook } = {}) {
|
|
|
250
191
|
if ((callers.get(who) ?? 0) >= MAX_SUBSCRIPTIONS_PER_CALLER)
|
|
251
192
|
throw refusal({ code: RESOURCE_EXHAUSTED, message: "ResourceExhausted", data: { limit: "subscriptionsPerCaller", max: MAX_SUBSCRIPTIONS_PER_CALLER } });
|
|
252
193
|
subscribed++;
|
|
253
|
-
callers
|
|
194
|
+
count(callers, who, 1);
|
|
195
|
+
// A presence keeps a subscriber's value while any of its streams or leases is open.
|
|
196
|
+
const subscriber = named(args);
|
|
197
|
+
const hold = (by) => {
|
|
198
|
+
count(type.holds, subscriber, by);
|
|
199
|
+
for (const keep of type.present)
|
|
200
|
+
keep(subscriber);
|
|
201
|
+
};
|
|
202
|
+
hold(1);
|
|
254
203
|
let entry = type.subscriptions.get(id);
|
|
255
204
|
if (!entry) {
|
|
256
205
|
const subscription = { subscriptionId: id, name: type.name, delivery, arguments: args, context };
|
|
@@ -261,16 +210,13 @@ export function createEvents({ webhookTtl, webhook } = {}) {
|
|
|
261
210
|
}
|
|
262
211
|
const current = entry;
|
|
263
212
|
let closed = false;
|
|
264
|
-
const held = { id,
|
|
213
|
+
const held = { id, arguments: args, context, end, close() {
|
|
265
214
|
if (closed)
|
|
266
215
|
return;
|
|
267
216
|
closed = true;
|
|
268
217
|
subscribed--;
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
callers.delete(who);
|
|
272
|
-
else
|
|
273
|
-
callers.set(who, count - 1);
|
|
218
|
+
count(callers, who, -1);
|
|
219
|
+
hold(-1);
|
|
274
220
|
current.held.delete(held);
|
|
275
221
|
if (current.held.size)
|
|
276
222
|
return;
|
|
@@ -312,13 +258,12 @@ export function createEvents({ webhookTtl, webhook } = {}) {
|
|
|
312
258
|
lent = undefined;
|
|
313
259
|
}
|
|
314
260
|
if (!lent) {
|
|
315
|
-
const fresh = { type }
|
|
261
|
+
const fresh = { type, opening: open(type, { ...subscribed, id: key, delivery: "poll" }, error => {
|
|
262
|
+
fresh.ended = error;
|
|
263
|
+
fresh.release?.();
|
|
264
|
+
}) };
|
|
316
265
|
lent = fresh;
|
|
317
266
|
leases.set(key, fresh);
|
|
318
|
-
fresh.opening = open(type, { ...subscribed, id: key, delivery: "poll" }, error => {
|
|
319
|
-
fresh.ended = error;
|
|
320
|
-
fresh.release?.();
|
|
321
|
-
});
|
|
322
267
|
fresh.opening.then(held => {
|
|
323
268
|
if (shared)
|
|
324
269
|
held.untargetable = true;
|
|
@@ -344,57 +289,36 @@ export function createEvents({ webhookTtl, webhook } = {}) {
|
|
|
344
289
|
current.timer.unref?.();
|
|
345
290
|
return held;
|
|
346
291
|
};
|
|
347
|
-
// What a delivery mode works with: `subscription` checks a request, names its event type and the subscription's
|
|
348
|
-
// id, `open` begins holding a subscription, `read` gives a subscription what it is owed after a cursor (`since` and
|
|
349
|
-
// `shape` for emitted events alone), `position` is a type's cursor, a type's `sinks` hear what is emitted, and its
|
|
350
|
-
// `subscriptions` are the ones held by every mode, by id.
|
|
351
|
-
const internals = { types, subscription, open, read, since, shape, position };
|
|
352
292
|
return {
|
|
353
|
-
[INTERNALS]: internals,
|
|
354
293
|
/**
|
|
355
294
|
* Declares an event type and returns the function that emits it. `match` decides which subscriptions receive an
|
|
356
|
-
* event from their arguments, and `transform` shapes its data for each.
|
|
357
|
-
* cursor, for event types whose source keeps its own history.
|
|
295
|
+
* event from their arguments, and `transform` shapes its data for each.
|
|
358
296
|
*/
|
|
359
|
-
event(name, { description, inputSchema, payloadSchema, _meta, match, transform,
|
|
297
|
+
event(name, { description, inputSchema, payloadSchema, _meta, match, transform, delivery = OFFERED, authorize, onSubscribe, onUnsubscribe }) {
|
|
360
298
|
if (types.has(name))
|
|
361
299
|
throw new Error(`Event ${name} is already declared.`);
|
|
362
|
-
const unknown = delivery.filter(mode => !
|
|
300
|
+
const unknown = delivery.filter(mode => !OFFERED.includes(mode));
|
|
363
301
|
if (unknown.length || !delivery.length)
|
|
364
|
-
throw new Error(`Event ${name} is delivered by ${delivery.join(", ") || "nothing"}, but only ${
|
|
365
|
-
// Webhook delivery sends what is emitted, and does not ask a check function.
|
|
366
|
-
if (check && delivery.includes("webhook"))
|
|
367
|
-
throw new Error(`Event ${name} has a check function, which webhook delivery does not read.`);
|
|
302
|
+
throw new Error(`Event ${name} is delivered by ${delivery.join(", ") || "nothing"}, but only ${OFFERED.join(", ")} are served.`);
|
|
368
303
|
const type = {
|
|
369
|
-
name, order: ++declared, description, inputSchema, payloadSchema, _meta, match, transform,
|
|
370
|
-
sequence: 0, dropped: 0, kept: [], sinks: new Set(), subscriptions: new Map(),
|
|
304
|
+
name, order: ++declared, description, inputSchema, payloadSchema, _meta, delivery, match, transform, authorize, onSubscribe, onUnsubscribe,
|
|
305
|
+
sequence: 0, dropped: 0, kept: [], sinks: new Set(), subscriptions: new Map(), holds: new Map(), present: new Set(),
|
|
371
306
|
};
|
|
372
307
|
types.set(name, type);
|
|
373
|
-
|
|
308
|
+
const emitter = {
|
|
374
309
|
emit(data, { eventId = crypto.randomUUID(), timestamp = new Date().toISOString(), subscriptionId: target } = {}) {
|
|
375
310
|
const occurrence = { eventId, name, timestamp, data };
|
|
376
311
|
// Data that cannot be sent as JSON is refused here, before anything is kept or sent.
|
|
377
312
|
JSON.stringify(occurrence);
|
|
378
|
-
// A type with a check function is asked for what polls are owed, so what it emits is pushed and not kept.
|
|
379
|
-
if (type.check) {
|
|
380
|
-
// An event for one subscription that has no stream to take it is lost.
|
|
381
|
-
if (target !== undefined && ![...type.subscriptions.get(target)?.held ?? []].some(held => held.delivery !== "poll"))
|
|
382
|
-
owe(type, target);
|
|
383
|
-
for (const sink of type.sinks)
|
|
384
|
-
sink({ target, occurrence });
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
313
|
// An event for one subscription is kept like any other, so the subscription has it when it comes back.
|
|
388
314
|
const kept = { sequence: ++type.sequence, at: Date.now(), target, occurrence };
|
|
389
315
|
type.kept.push(kept);
|
|
390
316
|
while (type.kept.length > KEPT_EVENTS || type.kept[0].at < kept.at - KEPT_AGE)
|
|
391
317
|
type.dropped = type.kept.shift().sequence;
|
|
392
318
|
for (const sink of type.sinks)
|
|
393
|
-
sink(
|
|
319
|
+
sink();
|
|
394
320
|
},
|
|
395
321
|
cursor() {
|
|
396
|
-
if (type.check)
|
|
397
|
-
throw new Error(`Event ${name} has a check function, whose cursor is its source's own.`);
|
|
398
322
|
return position(type);
|
|
399
323
|
},
|
|
400
324
|
terminate(id, error = forbidden) {
|
|
@@ -410,13 +334,44 @@ export function createEvents({ webhookTtl, webhook } = {}) {
|
|
|
410
334
|
for (const each of [...type.subscriptions.keys()])
|
|
411
335
|
endSubscription(type, each, removed);
|
|
412
336
|
},
|
|
337
|
+
presence(told) {
|
|
338
|
+
const values = new Map();
|
|
339
|
+
// A value has no timer while its subscriber holds a subscription, and leaves its linger after it stops.
|
|
340
|
+
const keep = (subscriber) => {
|
|
341
|
+
const shown = values.get(subscriber);
|
|
342
|
+
if (!shown)
|
|
343
|
+
return;
|
|
344
|
+
clearTimeout(shown.leaving);
|
|
345
|
+
if (type.holds.has(subscriber))
|
|
346
|
+
return;
|
|
347
|
+
shown.leaving = setTimeout(() => {
|
|
348
|
+
values.delete(subscriber);
|
|
349
|
+
emitter.emit(told(shown.args, undefined));
|
|
350
|
+
}, shown.linger);
|
|
351
|
+
shown.leaving.unref?.();
|
|
352
|
+
};
|
|
353
|
+
type.present.add(keep);
|
|
354
|
+
return {
|
|
355
|
+
set(args, value, linger) {
|
|
356
|
+
const subscriber = named(args);
|
|
357
|
+
values.set(subscriber, { args, value, linger, leaving: values.get(subscriber)?.leaving });
|
|
358
|
+
keep(subscriber);
|
|
359
|
+
emitter.emit(told(args, value));
|
|
360
|
+
},
|
|
361
|
+
seen(args) {
|
|
362
|
+
return [...values.values()]
|
|
363
|
+
.filter(shown => !type.match || type.match(told(shown.args, shown.value), args, undefined))
|
|
364
|
+
.map(({ value }) => value);
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
},
|
|
413
368
|
};
|
|
369
|
+
return emitter;
|
|
414
370
|
},
|
|
415
371
|
/** Serves the declared event types on an MCP server: events/list, events/poll and events/stream. */
|
|
416
372
|
serve(mcp) {
|
|
417
|
-
const underlying = "server" in mcp ? mcp.server : mcp;
|
|
418
373
|
// The custom methods are registered through the parts of the server this module uses.
|
|
419
|
-
const server =
|
|
374
|
+
const server = ("server" in mcp ? mcp.server : mcp);
|
|
420
375
|
// The sketch declares capabilities.events. The MCP SDK's client drops top-level capabilities it does not know,
|
|
421
376
|
// so the extension is declared under capabilities.extensions too, where SDK clients read it.
|
|
422
377
|
// Clients are never told the event types changed: over Holon's stateless HTTP, the notification reaches none.
|
|
@@ -446,32 +401,43 @@ export function createEvents({ webhookTtl, webhook } = {}) {
|
|
|
446
401
|
...(start + PAGE_SIZE < all.length && { nextCursor: String(page.at(-1).order) }),
|
|
447
402
|
};
|
|
448
403
|
});
|
|
449
|
-
for (const mode of modes)
|
|
450
|
-
mode.serve(underlying, internals);
|
|
451
404
|
server.setRequestHandler("events/poll", { params }, async (request, ctx) => {
|
|
452
405
|
const { type, subscription: subscribed } = await subscription(request, ctx.http, "poll");
|
|
406
|
+
// Taken before the lease, which waits for onSubscribe. The sketch's poll from null returns no events and a
|
|
407
|
+
// fresh cursor: this one is where the subscription opened, so the next poll has what onSubscribe emitted.
|
|
408
|
+
const opened = type.sequence;
|
|
453
409
|
const held = await lease(type, subscribed);
|
|
454
410
|
// Each poll is answered for its own caller, with the lease's id.
|
|
455
|
-
const { events, start, failures,
|
|
456
|
-
const lost = !held.untargetable && type.owed.delete(held.id);
|
|
411
|
+
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 }) });
|
|
457
412
|
// A poll carries its cursor once, for the whole response.
|
|
458
|
-
return { events: events.map(({ cursor, ...event }) => event), ...rest,
|
|
413
|
+
return { events: events.map(({ cursor, ...event }) => event), ...rest, nextPollMs: INTERVAL };
|
|
459
414
|
});
|
|
460
415
|
server.setRequestHandler("events/stream", { params }, async (request, ctx) => {
|
|
461
|
-
const { type
|
|
462
|
-
const type = state;
|
|
416
|
+
const { type, subscription: subscribed } = await subscription(request, ctx.http, "push");
|
|
463
417
|
const { signal } = ctx.mcpReq;
|
|
464
418
|
const meta = { [SUBSCRIPTION_ID_META_KEY]: ctx.mcpReq.id };
|
|
465
419
|
let done = false;
|
|
466
420
|
let ending;
|
|
421
|
+
// Set when the app terminates the stream: the last event emitted before, which the stream still sends.
|
|
422
|
+
let until;
|
|
467
423
|
let wake = () => { };
|
|
468
424
|
const finish = () => {
|
|
469
425
|
done = true;
|
|
470
426
|
wake();
|
|
471
427
|
};
|
|
428
|
+
// What is emitted from here on is the stream's, so it has what onSubscribe emits to it.
|
|
429
|
+
const opened = type.sequence;
|
|
472
430
|
const held = await open(type, { ...subscribed, delivery: "push" }, error => {
|
|
431
|
+
// Lost access ends delivery at once, even while what came before is still being sent. Otherwise only the
|
|
432
|
+
// first end counts, and the stream first sends what was emitted before it.
|
|
433
|
+
const lost = error.code === FORBIDDEN;
|
|
434
|
+
if (ending && !lost)
|
|
435
|
+
return;
|
|
473
436
|
ending = error;
|
|
474
|
-
|
|
437
|
+
if (lost)
|
|
438
|
+
return finish();
|
|
439
|
+
until = type.sequence;
|
|
440
|
+
wake();
|
|
475
441
|
});
|
|
476
442
|
// A client that left while the subscription was being set up is not waited for.
|
|
477
443
|
if (signal.aborted) {
|
|
@@ -499,22 +465,16 @@ export function createEvents({ webhookTtl, webhook } = {}) {
|
|
|
499
465
|
if ((sent += JSON.stringify(params).length) > STREAM_BUDGET)
|
|
500
466
|
finish();
|
|
501
467
|
};
|
|
502
|
-
// The stream follows its cursor
|
|
503
|
-
//
|
|
504
|
-
//
|
|
468
|
+
// The stream follows its cursor, and emitted events wake it. What it is owed waits in the buffer, not here, so
|
|
469
|
+
// a slow client is not owed more memory than one batch; when what it is owed is no longer kept, it is told of
|
|
470
|
+
// the gap.
|
|
505
471
|
let cursor = request.cursor ?? null;
|
|
506
472
|
let behind = true;
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
pushed.push(kept);
|
|
513
|
-
if (pushed.length > KEPT_EVENTS)
|
|
514
|
-
pushed.shift();
|
|
515
|
-
}
|
|
516
|
-
else
|
|
517
|
-
behind = true;
|
|
473
|
+
const sink = () => {
|
|
474
|
+
// Nothing emitted after the app terminated the stream is for it.
|
|
475
|
+
if (until !== undefined)
|
|
476
|
+
return;
|
|
477
|
+
behind = true;
|
|
518
478
|
wake();
|
|
519
479
|
};
|
|
520
480
|
type.sinks.add(sink);
|
|
@@ -539,71 +499,36 @@ export function createEvents({ webhookTtl, webhook } = {}) {
|
|
|
539
499
|
}, HEARTBEAT);
|
|
540
500
|
let first = true;
|
|
541
501
|
try {
|
|
542
|
-
let due = 0;
|
|
543
502
|
while (!done) {
|
|
544
|
-
if (
|
|
503
|
+
if (behind) {
|
|
545
504
|
behind = false;
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
await notify("error", { error
|
|
555
|
-
|
|
556
|
-
due = Date.now() + type.interval;
|
|
557
|
-
if (result) {
|
|
558
|
-
// A check function's gap is told after the events that follow it, at the position after them, since
|
|
559
|
-
// the position before them is not known and the client's cursor must not go back. The stream is
|
|
560
|
-
// confirmed before anything else is said on it.
|
|
561
|
-
const after = type.check && result.truncated && !first;
|
|
562
|
-
const truncated = result.truncated || first && type.owed.delete(held.id);
|
|
563
|
-
if (first || truncated && !after)
|
|
564
|
-
await notify("active", { cursor: type.check && result.truncated ? cursor : result.start, truncated });
|
|
565
|
-
if (first)
|
|
566
|
-
beat = heartbeats();
|
|
567
|
-
first = false;
|
|
568
|
-
for (const error of result.failures ?? [])
|
|
569
|
-
await notify("error", { error });
|
|
570
|
-
for (const event of result.events) {
|
|
571
|
-
if (done)
|
|
572
|
-
break;
|
|
573
|
-
await notify("event", event);
|
|
574
|
-
cursor = event.cursor ?? null;
|
|
575
|
-
}
|
|
505
|
+
const result = since(type, held, cursor, { ...(first && request.maxAgeMs !== undefined && { maxAgeMs: request.maxAgeMs }), opened, until });
|
|
506
|
+
// The stream is confirmed before anything else is said on it.
|
|
507
|
+
if (first || result.truncated)
|
|
508
|
+
await notify("active", { cursor: result.start, truncated: result.truncated });
|
|
509
|
+
if (first)
|
|
510
|
+
beat = heartbeats();
|
|
511
|
+
first = false;
|
|
512
|
+
for (const error of result.failures)
|
|
513
|
+
await notify("error", { error });
|
|
514
|
+
for (const event of result.events) {
|
|
576
515
|
if (done)
|
|
577
516
|
break;
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
await notify("active", { cursor, truncated: true });
|
|
581
|
-
if (result.hasMore) {
|
|
582
|
-
due = 0;
|
|
583
|
-
behind = true;
|
|
584
|
-
}
|
|
517
|
+
await notify("event", event);
|
|
518
|
+
cursor = event.cursor ?? null;
|
|
585
519
|
}
|
|
520
|
+
if (done)
|
|
521
|
+
break;
|
|
522
|
+
cursor = result.cursor;
|
|
586
523
|
}
|
|
587
|
-
//
|
|
588
|
-
|
|
589
|
-
let failure;
|
|
590
|
-
const event = shape(type, pushed.shift(), held, error => { failure = error; });
|
|
591
|
-
if (failure)
|
|
592
|
-
await notify("error", { error: failure });
|
|
593
|
-
if (event)
|
|
594
|
-
await notify("event", { ...event, cursor });
|
|
595
|
-
}
|
|
596
|
-
if (done)
|
|
524
|
+
// Terminated by the app, the stream ends once it has sent what was emitted before.
|
|
525
|
+
if (done || until !== undefined && !behind)
|
|
597
526
|
break;
|
|
598
|
-
let timer;
|
|
599
527
|
await new Promise(resolve => {
|
|
600
528
|
wake = resolve;
|
|
601
|
-
if (done ||
|
|
529
|
+
if (done || behind)
|
|
602
530
|
resolve();
|
|
603
|
-
else if (type.check)
|
|
604
|
-
timer = setTimeout(resolve, Math.max(0, due - Date.now()));
|
|
605
531
|
});
|
|
606
|
-
clearTimeout(timer);
|
|
607
532
|
wake = () => { };
|
|
608
533
|
}
|
|
609
534
|
if (ending) {
|
|
@@ -615,9 +540,6 @@ export function createEvents({ webhookTtl, webhook } = {}) {
|
|
|
615
540
|
finally {
|
|
616
541
|
clearInterval(beat);
|
|
617
542
|
type.sinks.delete(sink);
|
|
618
|
-
// What was emitted to this subscription alone and not sent is owed to it as a gap when it comes back.
|
|
619
|
-
if (held.id === subscribed.lease && pushed.some(kept => kept.target === held.id))
|
|
620
|
-
owe(type, held.id);
|
|
621
543
|
held.close();
|
|
622
544
|
}
|
|
623
545
|
// The final frame, sent when the server ends the stream; when the client did, nobody is listening.
|