agent-comms 9.0.9 → 9.0.10

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.
Files changed (42) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +1 -1
  4. package/dist/bridges/user/web/contract.d.ts +1 -1
  5. package/dist/bridges/user/web/contract.d.ts.map +1 -1
  6. package/dist/bridges/user/web/contract.js +3 -2
  7. package/dist/bridges/user/web/contract.js.map +1 -1
  8. package/dist/bridges/user/web/dist/bundle.js +1 -1
  9. package/dist/bridges/user/web/dist/mesh-worker.js +4 -4
  10. package/dist/bridges/user/web/dist/sw.js +1 -1
  11. package/dist/bridges/user/web/event-id.d.ts +10 -0
  12. package/dist/bridges/user/web/event-id.d.ts.map +1 -0
  13. package/dist/bridges/user/web/event-id.js +14 -0
  14. package/dist/bridges/user/web/event-id.js.map +1 -0
  15. package/dist/bridges/user/web/event-publisher.d.ts +13 -3
  16. package/dist/bridges/user/web/event-publisher.d.ts.map +1 -1
  17. package/dist/bridges/user/web/event-publisher.js +59 -16
  18. package/dist/bridges/user/web/event-publisher.js.map +1 -1
  19. package/dist/bridges/user/web/frontend/event-pump.d.ts +4 -2
  20. package/dist/bridges/user/web/frontend/event-pump.d.ts.map +1 -1
  21. package/dist/bridges/user/web/frontend/event-pump.js +25 -7
  22. package/dist/bridges/user/web/frontend/event-pump.js.map +1 -1
  23. package/dist/bridges/user/web/frontend/mesh-worker.d.ts.map +1 -1
  24. package/dist/bridges/user/web/frontend/mesh-worker.js +4 -3
  25. package/dist/bridges/user/web/frontend/mesh-worker.js.map +1 -1
  26. package/dist/bridges/user/web/router.d.ts.map +1 -1
  27. package/dist/bridges/user/web/router.js +5 -6
  28. package/dist/bridges/user/web/router.js.map +1 -1
  29. package/dist/bridges/user/web/test/event-publisher.unit.test.d.ts +2 -0
  30. package/dist/bridges/user/web/test/event-publisher.unit.test.d.ts.map +1 -0
  31. package/dist/bridges/user/web/test/event-publisher.unit.test.js +245 -0
  32. package/dist/bridges/user/web/test/event-publisher.unit.test.js.map +1 -0
  33. package/dist/bridges/user/web/test/orpc-router.integration.test.js +10 -0
  34. package/dist/bridges/user/web/test/orpc-router.integration.test.js.map +1 -1
  35. package/package.json +2 -2
  36. package/src/bridges/user/web/contract.ts +5 -2
  37. package/src/bridges/user/web/dist/bundle.js +1 -1
  38. package/src/bridges/user/web/dist/mesh-worker.js +4 -4
  39. package/src/bridges/user/web/dist/sw.js +1 -1
  40. package/src/bridges/user/web/event-id.ts +18 -0
  41. package/src/bridges/user/web/event-publisher.ts +68 -18
  42. package/src/bridges/user/web/router.ts +5 -7
@@ -1 +1 @@
1
- (function(){var e=`agent-comms-v1`,t=[{"revision":"41c6737126547228482ecc2b359801e6","url":"mesh-worker.js"},{"revision":"f1ee3afb8c9e8f0d7315fd93d6457316","url":"index.html"},{"revision":"0bc3d9d0beacf516bee36c2e9be3835f","url":"bundle.js"}].map(e=>e.url);self.addEventListener(`install`,n=>{n.waitUntil(self.caches.open(e).then(async e=>e.addAll(t)).then(async()=>self.skipWaiting()))}),self.addEventListener(`activate`,t=>{t.waitUntil(self.caches.keys().then(async t=>Promise.all(t.filter(t=>t!==e).map(async e=>self.caches.delete(e)))))});var n=200,r=300;function i(e,t){return e.method===`GET`&&t.status>=n&&t.status<r}self.addEventListener(`fetch`,t=>{if(new URL(t.request.url).pathname.startsWith(`/api/`)){t.respondWith(fetch(t.request).then(n=>{if(i(t.request,n)){let r=n.clone();self.caches.open(e).then(async e=>e.put(t.request,r))}return n}).catch(async()=>self.caches.open(e).then(async e=>e.match(t.request)).then(e=>e??new Response(`Offline`,{status:503}))));return}t.respondWith(self.caches.open(e).then(async e=>e.match(t.request)).then(async n=>n||fetch(t.request).then(n=>{if(i(t.request,n)){let r=n.clone();self.caches.open(e).then(async e=>e.put(t.request,r))}return n})))})})();
1
+ (function(){var e=`agent-comms-v1`,t=[{"revision":"66fb8e407d25a3d34fd7df939ebd2e96","url":"mesh-worker.js"},{"revision":"f1ee3afb8c9e8f0d7315fd93d6457316","url":"index.html"},{"revision":"aef2de9b9f0ed47024e02762fc047436","url":"bundle.js"}].map(e=>e.url);self.addEventListener(`install`,n=>{n.waitUntil(self.caches.open(e).then(async e=>e.addAll(t)).then(async()=>self.skipWaiting()))}),self.addEventListener(`activate`,t=>{t.waitUntil(self.caches.keys().then(async t=>Promise.all(t.filter(t=>t!==e).map(async e=>self.caches.delete(e)))))});var n=200,r=300;function i(e,t){return e.method===`GET`&&t.status>=n&&t.status<r}self.addEventListener(`fetch`,t=>{if(new URL(t.request.url).pathname.startsWith(`/api/`)){t.respondWith(fetch(t.request).then(n=>{if(i(t.request,n)){let r=n.clone();self.caches.open(e).then(async e=>e.put(t.request,r))}return n}).catch(async()=>self.caches.open(e).then(async e=>e.match(t.request)).then(e=>e??new Response(`Offline`,{status:503}))));return}t.respondWith(self.caches.open(e).then(async e=>e.match(t.request)).then(async n=>n||fetch(t.request).then(n=>{if(i(t.request,n)){let r=n.clone();self.caches.open(e).then(async e=>e.put(t.request,r))}return n})))})})();
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The shape of an event id, shared by the publisher that issues ids and the contract that bounds what a client may send back, so neither can drift from the other.
3
+ */
4
+
5
+ /** Bytes of randomness in a publisher's instance; the instance is their hex form. */
6
+ export const INSTANCE_BYTES = 8;
7
+
8
+ /** The separator between an id's instance and its sequence number. Hex never contains it. */
9
+ export const ID_SEPARATOR = ".";
10
+
11
+ /** Characters of an instance: two hex digits per byte. */
12
+ const INSTANCE_LENGTH = INSTANCE_BYTES * 2;
13
+
14
+ /** The longest id a publisher can issue: its instance, the separator and the digits of the largest sequence number a JavaScript number holds exactly. A longer string cannot be an id of any publisher. */
15
+ export const MAX_EVENT_ID_LENGTH =
16
+ INSTANCE_LENGTH +
17
+ ID_SEPARATOR.length +
18
+ String(Number.MAX_SAFE_INTEGER).length;
@@ -1,13 +1,18 @@
1
1
  /**
2
2
  * In-memory, resumable event publisher for the web UI's subscribeEvents stream.
3
3
  *
4
- * oRPC v2 beta (`\@orpc/server`, `\@orpc/client`, `\@orpc/contract` at 2.0.0-beta.35 -- confirmed against the actual published tarballs, not just docs) ships no MemoryPublisher/EventPublisher class. This is a hand-written equivalent built on the primitives that do exist: a plain async generator (satisfies AsyncIteratorObject on its own) and withEventMeta (from `\@standard-server/core`, re-exported by `\@orpc/server`) to tag each published event with a monotonic id oRPC's own RetryLinkPlugin already knows how to read back as lastEventId on reconnect.
4
+ * oRPC v2 beta (`\@orpc/server`, `\@orpc/client`, `\@orpc/contract` at 2.0.0-beta.35 -- confirmed against the actual published tarballs, not just docs) ships no MemoryPublisher/EventPublisher class. This is a hand-written equivalent built on the primitives that do exist: a plain async generator (satisfies AsyncIteratorObject on its own) and withEventMeta (from `\@standard-server/core`, re-exported by `\@orpc/server`) to tag each published event with an id oRPC's own RetryLinkPlugin already knows how to read back as lastEventId on reconnect. An id is `<instance>.<seq>`: the instance is drawn at random when the publisher starts, so an id from before a restart, or from another publisher, can never be mistaken for one of this publisher's events.
5
5
  *
6
6
  * One instance per WebServerHandle -- not a module-level singleton -- so multiple concurrent web server instances (multiple bridges each starting their own web UI) never share replay buffers.
7
7
  */
8
8
 
9
9
  import { withEventMeta } from "@orpc/server";
10
10
  import type { MeshEvent } from "./contract.js";
11
+ import {
12
+ ID_SEPARATOR,
13
+ INSTANCE_BYTES,
14
+ MAX_EVENT_ID_LENGTH,
15
+ } from "./event-id.js";
11
16
 
12
17
  /** A plain function call is opaque to TS's control-flow narrowing, unlike a direct `signal?.aborted` comparison -- narrowing that across an `await` (the abort can genuinely flip mid-wait) produces a false "these types have no overlap" error at the re-check below the await. */
13
18
  function isAborted(signal: AbortSignal | undefined): boolean {
@@ -17,12 +22,20 @@ function isAborted(signal: AbortSignal | undefined): boolean {
17
22
  export interface MeshEventPublisherOptions {
18
23
  /** How long a published event stays available for replay by a resuming subscriber. */
19
24
  retentionMs?: number;
25
+ /** The most events kept for replay. Defaults to {@link DEFAULT_MAX_BUFFERED_EVENTS}. */
26
+ maxBufferedEvents?: number;
20
27
  }
21
28
 
22
29
  const DEFAULT_RETENTION_MS = 60_000;
23
30
 
31
+ /** The most events a publisher keeps for replay, whatever their age. An event can carry a whole mesh snapshot, so a burst of resynchronisations must not hold sixty seconds of them: past this a subscriber whose id fell off the end starts from a snapshot, as one whose id aged out does. */
32
+ const DEFAULT_MAX_BUFFERED_EVENTS = 1_000;
33
+
34
+ /** Radix of the hex form of the instance. */
35
+ const HEX_RADIX = 16;
36
+
24
37
  interface BufferedEvent {
25
- id: string;
38
+ seq: number;
26
39
  event: MeshEvent;
27
40
  publishedAt: number;
28
41
  }
@@ -30,23 +43,40 @@ interface BufferedEvent {
30
43
  export interface MeshEventSubscribeOptions {
31
44
  signal?: AbortSignal;
32
45
  lastEventId?: string;
46
+ /** Builds the snapshot a subscriber starts from when it has no usable id. The snapshot is built after the subscriber is listening, so no event published meanwhile is lost. */
47
+ snapshot: () => MeshEvent;
48
+ }
49
+
50
+ function drawInstance(): string {
51
+ return Array.from(
52
+ globalThis.crypto.getRandomValues(new Uint8Array(INSTANCE_BYTES)),
53
+ (byte) => byte.toString(HEX_RADIX).padStart(2, "0"),
54
+ ).join("");
33
55
  }
34
56
 
35
57
  export class MeshEventPublisher {
36
58
  private seq = 0;
59
+ private readonly instance = drawInstance();
37
60
  private readonly buffer: BufferedEvent[] = [];
38
61
  private readonly listeners = new Set<(event: MeshEvent) => void>();
39
62
  private readonly retentionMs: number;
63
+ private readonly maxBufferedEvents: number;
40
64
 
41
65
  constructor(options: Readonly<MeshEventPublisherOptions> = {}) {
42
66
  this.retentionMs = options.retentionMs ?? DEFAULT_RETENTION_MS;
67
+ this.maxBufferedEvents =
68
+ options.maxBufferedEvents ?? DEFAULT_MAX_BUFFERED_EVENTS;
43
69
  }
44
70
 
45
71
  publish(event: MeshEvent): void {
46
72
  this.prune();
47
- const id = String(++this.seq);
48
- const tagged = withEventMeta(event, { id });
49
- this.buffer.push({ id, event: tagged, publishedAt: Date.now() });
73
+ const seq = ++this.seq;
74
+ const tagged = withEventMeta(event, {
75
+ id: `${this.instance}${ID_SEPARATOR}${String(seq)}`,
76
+ });
77
+ this.buffer.push({ seq, event: tagged, publishedAt: Date.now() });
78
+ // Sequence numbers stay contiguous, so dropping from the head leaves "older than the oldest retained" exact.
79
+ while (this.buffer.length > this.maxBufferedEvents) this.buffer.shift();
50
80
  for (const listener of this.listeners) listener(tagged);
51
81
  }
52
82
 
@@ -61,24 +91,34 @@ export class MeshEventPublisher {
61
91
  }
62
92
 
63
93
  /**
64
- * Replays buffered events after `lastEventId` (or every buffered event, if a resuming subscriber's `lastEventId` has already aged out of the retention window), then yields new events as they're published until `signal` aborts.
94
+ * The events a subscriber holding `lastEventId` is missing, or undefined when they cannot be known: the id is not this publisher's, is malformed, or is older than the oldest retained event, so events between it and the retained ones are gone.
95
+ */
96
+ private missedSince(lastEventId: string): BufferedEvent[] | undefined {
97
+ // Refused before any split: no id of any publisher is this long.
98
+ if (lastEventId.length > MAX_EVENT_ID_LENGTH) return undefined;
99
+ const [instance, rawSeq, ...rest] = lastEventId.split(ID_SEPARATOR);
100
+ if (instance !== this.instance || rawSeq === undefined || rest.length > 0) {
101
+ return undefined;
102
+ }
103
+ if (!/^[0-9]+$/u.test(rawSeq)) return undefined;
104
+ const seq = Number(rawSeq);
105
+ if (seq > this.seq) return undefined;
106
+ const oldest = this.buffer[0];
107
+ // With nothing retained the only id that is not missing anything is the latest one.
108
+ if (oldest === undefined) return seq === this.seq ? [] : undefined;
109
+ if (seq < oldest.seq - 1) return undefined;
110
+ return this.buffer.filter((buffered) => buffered.seq > seq);
111
+ }
112
+
113
+ /**
114
+ * Starts a subscriber and yields to it until `signal` aborts. A subscriber with a usable `lastEventId` first gets exactly the events it missed. Any other subscriber, with no id or with one that cannot be resumed from (see {@link MeshEventPublisher.missedSince}), first gets `snapshot()`. Live events follow in both cases, and none published while the replay or snapshot is being delivered is lost.
65
115
  */
66
116
  async *subscribe(
67
- options: Readonly<MeshEventSubscribeOptions> = {},
117
+ options: Readonly<MeshEventSubscribeOptions>,
68
118
  ): AsyncGenerator<MeshEvent, void, void> {
69
- const { signal, lastEventId } = options;
119
+ const { signal, lastEventId, snapshot } = options;
70
120
  this.prune();
71
121
 
72
- if (lastEventId !== undefined) {
73
- const startIndex = this.buffer.findIndex((b) => b.id === lastEventId);
74
- const replay =
75
- startIndex === -1 ? this.buffer : this.buffer.slice(startIndex + 1);
76
- for (const buffered of replay) {
77
- if (isAborted(signal)) return;
78
- yield buffered.event;
79
- }
80
- }
81
-
82
122
  const queue: MeshEvent[] = [];
83
123
  let wake: (() => void) | undefined;
84
124
  const listener = (event: MeshEvent): void => {
@@ -88,6 +128,16 @@ export class MeshEventPublisher {
88
128
  this.listeners.add(listener);
89
129
 
90
130
  try {
131
+ const missed =
132
+ lastEventId === undefined ? undefined : this.missedSince(lastEventId);
133
+ if (missed === undefined) {
134
+ yield snapshot();
135
+ } else {
136
+ for (const buffered of missed) {
137
+ if (isAborted(signal)) return;
138
+ yield buffered.event;
139
+ }
140
+ }
91
141
  for (;;) {
92
142
  if (isAborted(signal)) return;
93
143
  if (queue.length === 0) {
@@ -131,16 +131,14 @@ export const meshRouter = {
131
131
  // lastEventId is set two ways: oRPC's own RetryLinkPlugin threads it automatically on a mid-stream reconnect (the protocol-level `lastEventId` above); a tab that reconnects from cold (a fresh subscribeEvents call, e.g. after being closed and reopened) has to pass it explicitly as input instead, since there's no in-flight call for the protocol layer to resume.
132
132
  const resumeFrom = lastEventId ?? input.lastEventId;
133
133
 
134
- if (resumeFrom === undefined) {
135
- yield {
136
- kind: "state_sync" as const,
137
- state: toBrowserState(context.controller.meshStore.serialise()),
138
- };
139
- }
140
-
134
+ // The publisher decides between replaying what the id's holder missed and starting from this snapshot: an id from before a restart, or older than the retained events, cannot be resumed from.
141
135
  yield* context.publisher.subscribe({
142
136
  ...(signal ? { signal } : {}),
143
137
  ...(resumeFrom !== undefined ? { lastEventId: resumeFrom } : {}),
138
+ snapshot: () => ({
139
+ kind: "state_sync" as const,
140
+ state: toBrowserState(context.controller.meshStore.serialise()),
141
+ }),
144
142
  });
145
143
  }),
146
144
  };