@haikit/client 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -135,6 +135,29 @@ name the registry doesn't list renders an error card. Like a surface, `mount`
135
135
  may return `{ unmount }`. With `createChat`, mount one yourself with
136
136
  `chat.mountNotice(seq, element)`.
137
137
 
138
+ ## Signals
139
+
140
+ A signal (`hai.signal`) is a broadcast to every open browser, such as how many
141
+ people are online. It comes down the same events stream, and goes to a
142
+ handler, never to the transcript:
143
+
144
+ ```js
145
+ mountChat({
146
+ root, registry,
147
+ signals: {
148
+ online(payload, { version }) {
149
+ badge.textContent = `${payload.count} reading now`;
150
+ },
151
+ },
152
+ });
153
+ ```
154
+
155
+ Each signal's latest payload is also in `state.signals`, by name, and
156
+ subscribers hear a `signal` event. A stream that opens is sent each signal's
157
+ latest value first, so the handler runs as soon as the events stream is up.
158
+ Signals carry no id: a reconnect is sent the latest values again rather than
159
+ the ones it missed.
160
+
138
161
  ## Out-of-date conversations
139
162
 
140
163
  The server closes a conversation once any surface passes its freshness window.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haikit/client",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "The haikit browser runtime: streams tool payloads into interactive UI surfaces. Plain ESM, no build step, no dependencies.",
5
5
  "keywords": [
6
6
  "haikit",
package/src/app.d.ts CHANGED
@@ -1,10 +1,12 @@
1
- import type { Chat, NoticeRegistry, Registry } from "./index.js";
1
+ import type { Chat, NoticeRegistry, Registry, SignalRegistry } from "./index.js";
2
2
 
3
3
  export interface MountChatOptions {
4
4
  root: HTMLElement;
5
5
  registry: Registry;
6
6
  /** Components for the notices the server sends, by notice name. */
7
7
  notices?: NoticeRegistry;
8
+ /** Handlers for the signals the server sends, by signal name. */
9
+ signals?: SignalRegistry;
8
10
  /** Base path the server's two routes are mounted at. Default `/hai`. */
9
11
  endpoint?: string;
10
12
  title?: string;
package/src/app.js CHANGED
@@ -101,6 +101,7 @@ export function mountChat({
101
101
  root,
102
102
  registry,
103
103
  notices,
104
+ signals,
104
105
  endpoint = "/hai",
105
106
  title = "HaiKIT",
106
107
  subtitle = "",
@@ -110,7 +111,7 @@ export function mountChat({
110
111
  emptyText = "",
111
112
  theme,
112
113
  }) {
113
- const chat = createChat({ endpoint, registry, notices });
114
+ const chat = createChat({ endpoint, registry, notices, signals });
114
115
  const id = `hai-${++shells}`;
115
116
 
116
117
  // Unset, the page follows the system. A pin goes on the root, where hai.css
package/src/index.d.ts CHANGED
@@ -128,6 +128,15 @@ export interface NoticeDef<P = any> {
128
128
  mount(element: HTMLElement, payload: P, ctx: NoticeMountCtx): NoticeInstance | null | void;
129
129
  }
130
130
 
131
+ /**
132
+ * What the browser does with a signal: a handler, called with its payload each
133
+ * time one arrives. Signals are never drawn in the transcript.
134
+ */
135
+ export type SignalHandler<P = any> = (payload: P, ctx: { version: number }) => void;
136
+
137
+ /** Handlers for the signals the server sends, by signal name. */
138
+ export type SignalRegistry = Record<string, SignalHandler<any>>;
139
+
131
140
  /** The notices allowlist, by notice name. Separate from `Registry`, so names never clash. */
132
141
  export type NoticeRegistry = Record<string, NoticeDef<any>>;
133
142
 
@@ -171,6 +180,8 @@ export interface ChatState {
171
180
  expired: string | null;
172
181
  /** Notices the server has sent this conversation, in order. */
173
182
  notices: NoticeRecord[];
183
+ /** Each signal's latest payload, by name, since the events stream opened. */
184
+ signals: Record<string, unknown>;
174
185
  }
175
186
 
176
187
  export interface Chat {
@@ -229,4 +240,9 @@ export interface Chat {
229
240
  * `GET {endpoint}/events` open for the conversation, resuming after the last
230
241
  * notice it saw if the connection drops, until `reset()` or `close()`.
231
242
  */
232
- export function createChat(options: { endpoint?: string; registry: Registry; notices?: NoticeRegistry }): Chat;
243
+ export function createChat(options: {
244
+ endpoint?: string;
245
+ registry: Registry;
246
+ notices?: NoticeRegistry;
247
+ signals?: SignalRegistry;
248
+ }): Chat;
package/src/index.js CHANGED
@@ -12,7 +12,7 @@
12
12
  /**
13
13
  * @param {{endpoint?: string, registry: Record<string, {mount: Function}>, notices?: Record<string, {mount: Function}>}} options
14
14
  */
15
- export function createChat({ endpoint = "/hai", registry, notices = {} }) {
15
+ export function createChat({ endpoint = "/hai", registry, notices = {}, signals = {} }) {
16
16
  const listeners = new Set();
17
17
  const state = {
18
18
  conversationId: null,
@@ -33,6 +33,11 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
33
33
  expired: null,
34
34
  /** Notices the server has sent this conversation, in the order they were sent. */
35
35
  notices: [],
36
+ /**
37
+ * Each signal's latest payload, by name, since the events stream opened.
38
+ * No prototype, so a signal can be named anything, `__proto__` included.
39
+ */
40
+ signals: Object.create(null),
36
41
  };
37
42
 
38
43
  const notify = (event) => listeners.forEach((fn) => fn(state, event));
@@ -251,6 +256,16 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
251
256
  break;
252
257
  }
253
258
 
259
+ // A broadcast to every open browser. Never stored, so there is nothing
260
+ // to resume: its latest value is kept, and its handler hears it.
261
+ case "signal": {
262
+ state.signals[event.name] = event.payload;
263
+ // the app's own handlers only, never one an object inherits, such as `toString`
264
+ const handler = Object.hasOwn(signals, event.name) ? signals[event.name] : undefined;
265
+ if (typeof handler === "function") attempt(() => handler(event.payload, { version: event.version }));
266
+ break;
267
+ }
268
+
254
269
  // A stream that rejoins a wake turn is sent what the turn has shown so
255
270
  // far: a block this client already has is brought up to date, not added
256
271
  // again.
@@ -743,6 +758,7 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
743
758
  expiresAt: null,
744
759
  expired: null,
745
760
  notices: [],
761
+ signals: Object.create(null),
746
762
  });
747
763
  state.surfaces.clear();
748
764
  replacedBy.clear();