stitchkit 0.75.1 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -80,8 +80,17 @@ export interface WatchHubConfig {
80
80
  read(operation: WatchOperation, args: unknown): Promise<unknown>;
81
81
  /** Whether an operation may be watched at all. Refusal is answered in words. */
82
82
  watchable(operation: WatchOperation): boolean;
83
- /** The topics whose announcement means this operation's answer may have changed. */
84
- invalidatedBy(operation: WatchOperation): readonly string[];
83
+ /**
84
+ * The topics whose announcement means this answer may have changed.
85
+ *
86
+ * Given the arguments as well as the operation, so a topic can name what the
87
+ * answer actually depends on: `chat.transcript:<address>` rather than
88
+ * `chat.transcript`. Without that narrowing, one address changing wakes every
89
+ * watcher of the operation — twenty conversations open means twenty reads for
90
+ * one change, and nineteen of them publish nothing because nothing changed.
91
+ * The read is still paid.
92
+ */
93
+ invalidatedBy(operation: WatchOperation, args: unknown): readonly string[];
85
94
  /** Subscribe to a topic; returns the unsubscribe. Normally an event bus's `on`. */
86
95
  subscribe(topic: string, listener: () => void): () => void;
87
96
  /** The most keys one subscriber may watch at once. Default 64. */
@@ -1 +1 @@
1
- {"version":3,"file":"watch-hub.d.ts","sourceRoot":"","sources":["../../src/application/watch-hub.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,OAAO,EAAE,KAAK,aAAa,EAAiB,MAAM,sBAAsB,CAAC;AAEzE,OAAO,EACL,KAAK,QAAQ,EACb,KAAK,eAAe,EACpB,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAChC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAE9C,mFAAmF;AACnF,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,eAAe;IAC9B,yDAAyD;IACzD,KAAK,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,CAAC;IACpC,oDAAoD;IACpD,KAAK,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,CAAC;CACrC;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,OAAO,GAAG;QAAE,QAAQ,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3E,KAAK,CAAC,GAAG,EAAE,QAAQ,GAAG,IAAI,CAAC;IAC3B,yEAAyE;IACzE,MAAM,IAAI,IAAI,CAAC;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B;;;;;;;;;;;;;;;OAeG;IACH,IAAI,CAAC,SAAS,EAAE,cAAc,EAAE,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACjE,gFAAgF;IAChF,SAAS,CAAC,SAAS,EAAE,cAAc,GAAG,OAAO,CAAC;IAC9C,oFAAoF;IACpF,aAAa,CAAC,SAAS,EAAE,cAAc,GAAG,SAAS,MAAM,EAAE,CAAC;IAC5D,mFAAmF;IACnF,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;IAC3D,kEAAkE;IAClE,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wCAAwC;IACxC,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB,yFAAyF;IACzF,IAAI,CAAC,CAAC,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC;IACjD,MAAM,CAAC,EAAE,YAAY,CAAC;CACvB;AAED,MAAM,WAAW,QAAQ;IACvB,MAAM,CAAC,UAAU,EAAE,eAAe,GAAG,eAAe,CAAC;IACrD;;;;;;OAMG;IACH,SAAS,IAAI,MAAM,CAAC;IACpB,qEAAqE;IACrE,IAAI,IAAI,MAAM,CAAC;IACf,KAAK,IAAI,IAAI,CAAC;CACf;AAqBD;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,SAAS,EAAE,cAAc,EAAE,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAU1F;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,GAAG,QAAQ,CA0M/D"}
1
+ {"version":3,"file":"watch-hub.d.ts","sourceRoot":"","sources":["../../src/application/watch-hub.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,OAAO,EAAE,KAAK,aAAa,EAAiB,MAAM,sBAAsB,CAAC;AAEzE,OAAO,EACL,KAAK,QAAQ,EACb,KAAK,eAAe,EACpB,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAChC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAE9C,mFAAmF;AACnF,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,eAAe;IAC9B,yDAAyD;IACzD,KAAK,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,CAAC;IACpC,oDAAoD;IACpD,KAAK,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,CAAC;CACrC;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,OAAO,GAAG;QAAE,QAAQ,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3E,KAAK,CAAC,GAAG,EAAE,QAAQ,GAAG,IAAI,CAAC;IAC3B,yEAAyE;IACzE,MAAM,IAAI,IAAI,CAAC;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B;;;;;;;;;;;;;;;OAeG;IACH,IAAI,CAAC,SAAS,EAAE,cAAc,EAAE,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACjE,gFAAgF;IAChF,SAAS,CAAC,SAAS,EAAE,cAAc,GAAG,OAAO,CAAC;IAC9C;;;;;;;;;OASG;IACH,aAAa,CAAC,SAAS,EAAE,cAAc,EAAE,IAAI,EAAE,OAAO,GAAG,SAAS,MAAM,EAAE,CAAC;IAC3E,mFAAmF;IACnF,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;IAC3D,kEAAkE;IAClE,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wCAAwC;IACxC,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB,yFAAyF;IACzF,IAAI,CAAC,CAAC,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC;IACjD,MAAM,CAAC,EAAE,YAAY,CAAC;CACvB;AAED,MAAM,WAAW,QAAQ;IACvB,MAAM,CAAC,UAAU,EAAE,eAAe,GAAG,eAAe,CAAC;IACrD;;;;;;OAMG;IACH,SAAS,IAAI,MAAM,CAAC;IACpB,qEAAqE;IACrE,IAAI,IAAI,MAAM,CAAC;IACf,KAAK,IAAI,IAAI,CAAC;CACf;AAqBD;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,SAAS,EAAE,cAAc,EAAE,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAU1F;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,GAAG,QAAQ,CA4M/D"}
@@ -1610,7 +1610,7 @@ function createWatchHub(config) {
1610
1610
  state: { key, phase: "opening" },
1611
1611
  backoff: createBackoff(config.backoff ?? DEFAULT_BACKOFF)
1612
1612
  };
1613
- for (const topic of config.invalidatedBy(operation)) {
1613
+ for (const topic of config.invalidatedBy(operation, args)) {
1614
1614
  source.unsubscribes.push(config.subscribe(topic, () => {
1615
1615
  source.dirty = true;
1616
1616
  pump(source);
@@ -17,6 +17,23 @@
17
17
  * `createUrlBuilder`, and the identity is the contract's own
18
18
  * `(prefix, endpoint key)` — the same pair the server labels every request with.
19
19
  *
20
+ * ## A connection is not forever, and recovering is this client's job
21
+ *
22
+ * A socket drops and comes back, and the hub forgets everything the old
23
+ * connection held — it releases a subscriber's keys the moment that subscriber
24
+ * detaches. So a client that opened each question once and never again is a
25
+ * client whose face freezes the first time a server restarts, *silently*,
26
+ * because nothing tells the subscriber that what it is looking at stopped being
27
+ * live.
28
+ *
29
+ * This client holds every key and every listener, so nobody else can do it: on a
30
+ * drop it tells subscribers the source is gone and forgets that anything was
31
+ * opened; on a fresh connection it re-opens every key that still has a listener.
32
+ * That is why {@link WatchTransport} **requires** `onConnectionChange` rather
33
+ * than using it when offered — a transport that cannot say when it reconnected
34
+ * cannot host a recovering client, and an optional hook would turn recovery into
35
+ * something that silently did not happen.
36
+ *
20
37
  * ## Retention
21
38
  *
22
39
  * The last value of a key is kept while anyone holds it, and for `holdMs` after
@@ -49,6 +66,15 @@ export interface WatchTransport {
49
66
  accepted: boolean;
50
67
  reason?: string;
51
68
  }>;
69
+ /**
70
+ * Observe connection changes. Required, because recovery depends on it.
71
+ *
72
+ * The hub releases a subscriber's keys when its connection detaches, so every
73
+ * question opened over the old socket is gone the moment it drops. Without
74
+ * this the client would go on believing it was subscribed and show a face that
75
+ * had quietly stopped updating.
76
+ */
77
+ onConnectionChange(listener: (connected: boolean, reason?: string) => void): () => void;
52
78
  }
53
79
  export interface WatchClientConfig {
54
80
  readonly transport: WatchTransport;
@@ -1 +1 @@
1
- {"version":3,"file":"watch-client.d.ts","sourceRoot":"","sources":["../../src/live/watch-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEnE,OAAO,EAKL,KAAK,QAAQ,EACb,KAAK,eAAe,EAGrB,MAAM,kBAAkB,CAAC;AAE1B,MAAM,WAAW,cAAc,CAAC,MAAM;IACpC,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,kEAAkE;IAClE,KAAK,CAAC,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,CAAC;CACtC;AAED,MAAM,WAAW,WAAW,CAAC,MAAM;IACjC,sFAAsF;IACtF,SAAS,CAAC,SAAS,EAAE,cAAc,CAAC,MAAM,CAAC,GAAG,MAAM,IAAI,CAAC;IACzD,wEAAwE;IACxE,KAAK,IAAI,IAAI,CAAC;CACf;AAED,6FAA6F;AAC7F,MAAM,WAAW,cAAc;IAC7B,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,OAAO,EAAE,KAAK,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IACjE,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IAC5C,OAAO,CACL,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,GAC7B,OAAO,CAAC;QAAE,QAAQ,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACpD;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,SAAS,EAAE,cAAc,CAAC;IACnC,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,8DAA8D;IAC9D,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,wEAAwE;IACxE,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9D;AAcD,MAAM,MAAM,gBAAgB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,IAAI;KACnE,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,WAAW,CAAC,OAAO,CAAC;CACzE,CAAC;AAEF,wBAAgB,iBAAiB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACrE,QAAQ,EAAE,WAAW,CAAC,CAAC,EAAE,MAAM,CAAC,EAChC,MAAM,EAAE,iBAAiB,GACxB,gBAAgB,CAAC,CAAC,CAAC,CAqKrB"}
1
+ {"version":3,"file":"watch-client.d.ts","sourceRoot":"","sources":["../../src/live/watch-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEnE,OAAO,EAKL,KAAK,QAAQ,EACb,KAAK,eAAe,EAGrB,MAAM,kBAAkB,CAAC;AAE1B,MAAM,WAAW,cAAc,CAAC,MAAM;IACpC,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,kEAAkE;IAClE,KAAK,CAAC,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,CAAC;CACtC;AAED,MAAM,WAAW,WAAW,CAAC,MAAM;IACjC,sFAAsF;IACtF,SAAS,CAAC,SAAS,EAAE,cAAc,CAAC,MAAM,CAAC,GAAG,MAAM,IAAI,CAAC;IACzD,wEAAwE;IACxE,KAAK,IAAI,IAAI,CAAC;CACf;AAED,6FAA6F;AAC7F,MAAM,WAAW,cAAc;IAC7B,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,OAAO,EAAE,KAAK,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IACjE,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IAC5C,OAAO,CACL,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,GAC7B,OAAO,CAAC;QAAE,QAAQ,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACnD;;;;;;;OAOG;IACH,kBAAkB,CAAC,QAAQ,EAAE,CAAC,SAAS,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;CACzF;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,SAAS,EAAE,cAAc,CAAC;IACnC,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,8DAA8D;IAC9D,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,wEAAwE;IACxE,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9D;AAuBD,MAAM,MAAM,gBAAgB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,IAAI;KACnE,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,WAAW,CAAC,OAAO,CAAC;CACzE,CAAC;AAEF,wBAAgB,iBAAiB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACrE,QAAQ,EAAE,WAAW,CAAC,CAAC,EAAE,MAAM,CAAC,EAChC,MAAM,EAAE,iBAAiB,GACxB,gBAAgB,CAAC,CAAC,CAAC,CAwNrB"}
package/dist/live.js CHANGED
@@ -122,6 +122,11 @@ function createWatchClient(contract, config) {
122
122
  const holdMs = config.holdMs ?? 0;
123
123
  const openTimeoutMs = config.openTimeoutMs ?? 1e4;
124
124
  const service = contract.meta.prefix;
125
+ function publishState(entry, state) {
126
+ entry.state = state;
127
+ for (const listener of [...entry.listeners])
128
+ listener.state?.(state);
129
+ }
125
130
  config.transport.on(WATCH_VALUE, (frame) => {
126
131
  const entry = entries.get(watchKeyString(frame.key));
127
132
  if (!entry)
@@ -138,9 +143,27 @@ function createWatchClient(contract, config) {
138
143
  const entry = entries.get(watchKeyString(frame.key));
139
144
  if (!entry)
140
145
  return;
141
- entry.state = frame;
142
- for (const listener of [...entry.listeners])
143
- listener.state?.(frame);
146
+ publishState(entry, frame);
147
+ });
148
+ config.transport.onConnectionChange((connected, reason) => {
149
+ if (!connected) {
150
+ for (const entry of entries.values()) {
151
+ entry.opened = false;
152
+ publishState(entry, {
153
+ key: entry.key,
154
+ phase: "unavailable",
155
+ reason: "source-unavailable",
156
+ ...reason !== undefined && { message: reason }
157
+ });
158
+ }
159
+ return;
160
+ }
161
+ for (const entry of entries.values()) {
162
+ if (entry.listeners.size === 0)
163
+ continue;
164
+ publishState(entry, { key: entry.key, phase: "opening" });
165
+ open(entry);
166
+ }
144
167
  });
145
168
  function cachedDigest(action, args) {
146
169
  return digests.get(`${service}/${action}/${JSON.stringify(stableValue(args))}`);
@@ -154,7 +177,7 @@ function createWatchClient(contract, config) {
154
177
  digests.set(cacheKey, digest);
155
178
  return digest;
156
179
  }
157
- function entryFor(key) {
180
+ function entryFor(key, args) {
158
181
  const id = watchKeyString(key);
159
182
  const existing = entries.get(id);
160
183
  if (existing) {
@@ -164,14 +187,44 @@ function createWatchClient(contract, config) {
164
187
  }
165
188
  const entry = {
166
189
  key,
190
+ args,
167
191
  listeners: new Set,
168
192
  revision: 0,
169
193
  hasValue: false,
194
+ opened: false,
170
195
  state: { key, phase: "opening" }
171
196
  };
172
197
  entries.set(id, entry);
173
198
  return entry;
174
199
  }
200
+ async function open(entry) {
201
+ if (entry.opened)
202
+ return;
203
+ entry.opened = true;
204
+ try {
205
+ const acknowledgement = await config.transport.request(WATCH_OPEN, { key: entry.key, args: entry.args }, { timeoutMs: openTimeoutMs });
206
+ if (!acknowledgement.accepted) {
207
+ const reason = acknowledgement.reason ?? "the server refused this watch";
208
+ config.onRefused?.(entry.key, reason);
209
+ publishState(entry, {
210
+ key: entry.key,
211
+ phase: "unavailable",
212
+ reason: "source-unavailable",
213
+ message: reason
214
+ });
215
+ }
216
+ } catch (error) {
217
+ entry.opened = false;
218
+ const code = typeof error === "object" && error !== null && "code" in error ? String(Reflect.get(error, "code")) : undefined;
219
+ publishState(entry, {
220
+ key: entry.key,
221
+ phase: "unavailable",
222
+ reason: "source-unavailable",
223
+ ...code !== undefined && { code },
224
+ message: error instanceof Error ? error.message : String(error)
225
+ });
226
+ }
227
+ }
175
228
  function releaseEntry(entry) {
176
229
  if (entry.listeners.size > 0)
177
230
  return;
@@ -191,36 +244,16 @@ function createWatchClient(contract, config) {
191
244
  function handleFor(action, args) {
192
245
  const mine = new Set;
193
246
  let entry;
194
- let opened = false;
195
247
  const known = cachedDigest(action, args);
196
248
  if (known !== undefined)
197
- entry = entryFor({ service, action, digest: known });
249
+ entry = entryFor({ service, action, digest: known }, args);
198
250
  const ready = (async () => {
199
251
  if (entry)
200
252
  return entry;
201
253
  const digest = await resolveDigest(action, args);
202
- entry = entryFor({ service, action, digest });
254
+ entry = entryFor({ service, action, digest }, args);
203
255
  return entry;
204
256
  })();
205
- async function open(target) {
206
- if (opened)
207
- return;
208
- opened = true;
209
- const acknowledgement = await config.transport.request(WATCH_OPEN, { key: target.key, args }, { timeoutMs: openTimeoutMs });
210
- if (!acknowledgement.accepted) {
211
- const reason = acknowledgement.reason ?? "the server refused this watch";
212
- config.onRefused?.(target.key, reason);
213
- const refusal = {
214
- key: target.key,
215
- phase: "unavailable",
216
- reason: "source-unavailable",
217
- message: reason
218
- };
219
- target.state = refusal;
220
- for (const listener of [...target.listeners])
221
- listener.state?.(refusal);
222
- }
223
- }
224
257
  return {
225
258
  subscribe(listeners) {
226
259
  const registered = listeners;
@@ -875,6 +875,14 @@ function createTrustFence(config) {
875
875
  }
876
876
  trusted.push(authority);
877
877
  }
878
+ const trustedOrigins = [];
879
+ for (const entry of config.trustedOrigins ?? []) {
880
+ const authority = readAuthority(entry);
881
+ if (!authority) {
882
+ throw new Error(`[stitchkit] trust fence: trustedOrigins entry "${entry}" is not an authority. An entry is \`host\` or \`host:port\` — no scheme, no path, no wildcard, the same form as trustedHosts.`);
883
+ }
884
+ trustedOrigins.push(authority);
885
+ }
878
886
  function refuse(refusal) {
879
887
  config.onRefused?.(refusal);
880
888
  config.logger?.warn?.("[stitchkit] trust fence refused a request", {
@@ -911,7 +919,9 @@ function createTrustFence(config) {
911
919
  } catch {
912
920
  originAuthority = undefined;
913
921
  }
914
- const agrees = originAuthority !== undefined && originAuthority.hostname === requested.hostname && comparablePort(originAuthority.port) === comparablePort(requested.port);
922
+ const sameAuthority = originAuthority !== undefined && originAuthority.hostname === requested.hostname && comparablePort(originAuthority.port) === comparablePort(requested.port);
923
+ const declared = originAuthority !== undefined && trustedOrigins.some((entry) => entry.hostname === originAuthority.hostname && (entry.port === null || entry.port === originAuthority.port));
924
+ const agrees = sameAuthority || declared;
915
925
  if (!agrees) {
916
926
  return refuse({ reason: "origin-mismatch", lane, host, origin, site });
917
927
  }
@@ -84,6 +84,27 @@ export interface TrustFenceConfig {
84
84
  * list it when you want it.
85
85
  */
86
86
  readonly trustedHosts: readonly string[];
87
+ /**
88
+ * Origins allowed to address this server from a different authority.
89
+ *
90
+ * Same entry format as {@link trustedHosts} — `host` or `host:port`, compared
91
+ * against the `Origin`'s authority. Absent, only an `Origin` that matches the
92
+ * `Host` is accepted, which is the safe default and also refuses the most
93
+ * ordinary arrangement there is: a UI dev server on one port talking to an API
94
+ * on the next. That is the case this exists for.
95
+ *
96
+ * Worth knowing what the `Origin` check is and is not for, because the
97
+ * question comes up the moment it refuses something. It is **not** what stops
98
+ * DNS rebinding: that attack is same-origin by construction — the page believes
99
+ * it *is* the target authority — so it sends a matching `Origin` or none at
100
+ * all, and {@link trustedHosts} is what refuses it. What the `Origin` check
101
+ * stops is a plain cross-origin request from a page that never needs to read
102
+ * the reply: CORS governs whether a response can be *read*, never whether the
103
+ * request is *sent*, so a state-changing endpoint is reachable without it.
104
+ * That is why the answer here is a declared list rather than dropping the
105
+ * check.
106
+ */
107
+ readonly trustedOrigins?: readonly string[];
87
108
  /** Called on every refusal, before the 403 is written. */
88
109
  readonly onRefused?: (refusal: TrustRefusal) => void;
89
110
  /** Refusals are logged at `warn` when a logger is supplied. */
@@ -1 +1 @@
1
- {"version":3,"file":"trust-fence.d.ts","sourceRoot":"","sources":["../../../src/server/middleware/trust-fence.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AACjE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAE/C,qCAAqC;AACrC,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,QAAQ,CAAC;AAE1C,MAAM,MAAM,kBAAkB;AAC5B,yEAAyE;AACvE,cAAc;AAChB,wEAAwE;GACtE,gBAAgB;AAClB,iEAAiE;GAC/D,iBAAiB;AACnB,4DAA4D;GAC1D,YAAY,CAAC;AAEjB,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,MAAM,EAAE,kBAAkB,CAAC;IACpC,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IACzC,0DAA0D;IAC1D,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,IAAI,CAAC;IACrD,+DAA+D;IAC/D,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;CAChC;AAED,MAAM,WAAW,UAAU;IACzB;;;;;;OAMG;IACH,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAC,cAAc,EAAE,WAAW,CAAC,CAAC;IAClD,iFAAiF;IACjF,QAAQ,CAAC,YAAY,EAAE,qBAAqB,CAAC;IAC7C,oFAAoF;IACpF,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,GAAG,YAAY,GAAG,SAAS,CAAC;CACpE;AAyDD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAM1D;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,UAAU,CA2FrE"}
1
+ {"version":3,"file":"trust-fence.d.ts","sourceRoot":"","sources":["../../../src/server/middleware/trust-fence.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AACjE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAE/C,qCAAqC;AACrC,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,QAAQ,CAAC;AAE1C,MAAM,MAAM,kBAAkB;AAC5B,yEAAyE;AACvE,cAAc;AAChB,wEAAwE;GACtE,gBAAgB;AAClB,iEAAiE;GAC/D,iBAAiB;AACnB,4DAA4D;GAC1D,YAAY,CAAC;AAEjB,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,MAAM,EAAE,kBAAkB,CAAC;IACpC,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IACzC;;;;;;;;;;;;;;;;;;;OAmBG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5C,0DAA0D;IAC1D,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,IAAI,CAAC;IACrD,+DAA+D;IAC/D,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;CAChC;AAED,MAAM,WAAW,UAAU;IACzB;;;;;;OAMG;IACH,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAC,cAAc,EAAE,WAAW,CAAC,CAAC;IAClD,iFAAiF;IACjF,QAAQ,CAAC,YAAY,EAAE,qBAAqB,CAAC;IAC7C,oFAAoF;IACpF,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,GAAG,YAAY,GAAG,SAAS,CAAC;CACpE;AAyDD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAM1D;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,UAAU,CA6GrE"}
package/llms-full.txt CHANGED
@@ -62,7 +62,7 @@ own, recorded as an ADR.
62
62
  | `stitchkit/testing` | tests on Bun or Node | stable | in-process generated clients over a real Fetch handler, plus the store and managed-resource conformance kits |
63
63
  | `stitchkit/declaration` | build and deployment tooling (Bun or Node) | evolving | `ProjectDeclarationSchema` — the one machine-readable statement a repository makes about itself |
64
64
  | `stitchkit/react` | browser | stable | `createCursorQuery`, `createCacheBridge` |
65
- | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 11 of the 20 minors since 0.56.2, most recently 0.75.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
65
+ | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 11 of the 21 minors since 0.56.2, most recently 0.75.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
66
66
  | `stitchkit/agent-runtime/harness` | server | evolving | resource-aware process-local facade over the canonical Agent runtime; supervision stays outside |
67
67
  | `stitchkit/agent-runtime/coding-tools` | server (Bun or Node) | evolving | bounded host-authorized direct file and shell tools; a root boundary, not an OS sandbox |
68
68
  | `stitchkit/agent-runtime/openrouter` | server | evolving | isolated OpenRouter language-model adapter |
@@ -70,7 +70,7 @@ own, recorded as an ADR.
70
70
  | `stitchkit/agent-runtime/sqlite/bun` | server (Bun) | evolving | durable built-in SQLite store for the agent runtime |
71
71
  | `stitchkit/agent-runtime/sqlite/node` | server (Node ≥ 22.5) | evolving | durable built-in SQLite store for the agent runtime |
72
72
  | `stitchkit-tui` | terminal (Bun) | evolving | optional official OpenTUI host over a caller-composed headless runtime |
73
- | `stitchkit/application` | server | evolving<br>_redefined in 4 of the 20 minors since 0.56.2, most recently 0.72.0_ | managed resource graph, readiness, admission, schedules and bounded shutdown |
73
+ | `stitchkit/application` | server | evolving<br>_redefined in 4 of the 21 minors since 0.56.2, most recently 0.72.0_ | managed resource graph, readiness, admission, schedules and bounded shutdown |
74
74
  | `stitchkit/application/grammy` | server | evolving | isolated grammY polling and webhook lifecycle adapters |
75
75
  | `stitchkit/application/opentelemetry` | server | evolving | maps application snapshots onto an injected OpenTelemetry `Meter` |
76
76
 
@@ -7636,6 +7636,19 @@ order-independent — `{a, b}` and `{b, a}` are the same question, which a plain
7636
7636
  `JSON.stringify` key would get wrong exactly when two components happen to build
7637
7637
  their argument object in a different order.
7638
7638
 
7639
+ ### Narrow the topic to the arguments
7640
+
7641
+ `invalidatedBy` is given the arguments as well as the operation, so a topic can
7642
+ name what the answer actually depends on:
7643
+
7644
+ ```ts
7645
+ invalidatedBy: (operation, args) => [`chat.transcript:${args.address}`],
7646
+ ```
7647
+
7648
+ Without that, one address changing wakes every watcher of the operation. Twenty
7649
+ conversations open means twenty reads for one change — nineteen of them publish
7650
+ nothing, because nothing changed, and the read is paid anyway.
7651
+
7639
7652
  ### Who shares with whom
7640
7653
 
7641
7654
  A key is `(service, action, digest of arguments)`, and everyone on that key gets
@@ -7661,6 +7674,20 @@ A failed read arrives as `unavailable` with the read's own `code` and `message`.
7661
7674
  The hub retries on its own backoff, and a success clears the state without anyone
7662
7675
  asking.
7663
7676
 
7677
+ ### Across a reconnect
7678
+
7679
+ The hub releases a connection's keys when it detaches, so everything opened over
7680
+ the old socket is gone the moment it drops. The client recovers on its own: on a
7681
+ drop your `state` listener gets `unavailable` / `source-unavailable`, and on the
7682
+ next connection every key that still has a listener is re-opened and the values
7683
+ resume. That is what `onConnectionChange` on the transport is for, and why it is
7684
+ required rather than optional — a client that cannot be told has no way to
7685
+ notice, and the face stops updating without a word.
7686
+
7687
+ Nothing an open can fail with escapes as a rejected promise: a disconnected
7688
+ socket, a timeout, a refusal all arrive as `unavailable` carrying the error's own
7689
+ code and message, and the next connection retries.
7690
+
7664
7691
  ### `watch` or `createLiveStateController`?
7665
7692
 
7666
7693
  > **If you would have written `applyEvent` as `(_, next) => next`, you want
@@ -7745,6 +7772,32 @@ createServer({
7745
7772
  });
7746
7773
  ```
7747
7774
 
7775
+ ### A UI on one port, an API on the next
7776
+
7777
+ The most ordinary arrangement in development is also the one the fence refuses by
7778
+ default: a UI dev server on `:5180` calling an API on `:5181`. The browser sends
7779
+ `Origin: http://localhost:5180` to `Host: localhost:5181`, they differ, and both
7780
+ lanes answer 403. Declare the second origin:
7781
+
7782
+ ```ts
7783
+ createTrustFence({
7784
+ trustedHosts: ['localhost:5181'],
7785
+ trustedOrigins: ['localhost:5180'],
7786
+ });
7787
+ ```
7788
+
7789
+ Same entry format as `trustedHosts`, compared against the `Origin`'s authority.
7790
+ Declaring one does not widen anything else: an undeclared origin is still
7791
+ refused, and `trustedHosts` still decides which authority the server answers on.
7792
+
7793
+ It is worth knowing what the `Origin` check is for, because it is not what most
7794
+ people assume. It is **not** the DNS-rebinding defence — that attack is
7795
+ same-origin by construction, so it sends a matching `Origin` or none at all, and
7796
+ `trustedHosts` is what refuses it. What the `Origin` check stops is a plain
7797
+ cross-origin request from a page that never needs to read the reply: CORS governs
7798
+ whether a response can be *read*, never whether the request is *sent*, so a
7799
+ state-changing endpoint is reachable without it.
7800
+
7748
7801
  ### Install **both** halves
7749
7802
 
7750
7803
  `fence.hooks` fences HTTP. `fence.allowRequest` fences the Socket.IO lane — and
@@ -9918,6 +9971,28 @@ makes one thing your job rather than the resolver's:
9918
9971
  The mechanical part is identical either way. Only the *noticing* differs, and an
9919
9972
  exact pin moves it onto you.
9920
9973
 
9974
+ ## Released migration: 0.76.0
9975
+
9976
+ One change, and only if you hand `createWatchClient` a transport you wrote yourself.
9977
+
9978
+ ```bash
9979
+ rg -n "createWatchClient" --glob '*.ts'
9980
+ ```
9981
+
9982
+ If the `transport:` you pass is a bound realtime client — `bindRealtimeClient(...)` or
9983
+ `createRealtimeClient(...)` — there is nothing to do: it already carries
9984
+ `onConnectionChange`. If it is an object you assembled, add the fourth member:
9985
+
9986
+ ```ts
9987
+ onConnectionChange(listener: (connected: boolean, reason?: string) => void): () => void
9988
+ ```
9989
+
9990
+ It is required because the client recovers through it: on a drop it publishes
9991
+ `unavailable` to subscribers and forgets what was opened, and on a fresh
9992
+ connection it re-opens every key that still has a listener. Without it a watch
9993
+ stays "open" on the client after a server restart while the hub remembers
9994
+ nothing, and the face stops updating without saying so.
9995
+
9921
9996
  ## Released migration: 0.75.0
9922
9997
 
9923
9998
  Two mechanical renames and one option that has to move. Nothing about a passing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitchkit",
3
- "version": "0.75.1",
3
+ "version": "0.76.0",
4
4
  "description": "Contract-first backend framework — one defineContract() into an HTTP API, MCP tools, AI-agent tools and a typed client. Bun and Node.",
5
5
  "keywords": [
6
6
  "bun",