@jigs.design/client 0.2.0 → 0.3.1

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
@@ -8,13 +8,13 @@ import { createJigsClient } from "@jigs.design/client";
8
8
  export const client = createJigsClient({ endpoint: "/api/jigs" });
9
9
  ```
10
10
 
11
- Create one client per app, in its own module. Every view and session that imports it shares one query cache, so two views reading the same query make one request, and a mutation's invalidations reach both. The client retries network failures, aborts a query's request when nothing reads it any more, and refreshes the queries a mutation invalidates.
11
+ Create one client per app, in its own module. Every view and session that imports it shares one query cache, so two views reading the same query make one request, and a mutation's invalidations reach both. Pages' programs sit in a second cache, keyed by name and props, so a page and a query may share a name. The client retries network failures, aborts a query's request when nothing reads it any more, and refreshes the queries a mutation invalidates. When the server has `events`, it opens the push stream while anything is observed and refetches the queries and pages an event names.
12
12
 
13
13
  ## Exports
14
14
 
15
15
  | Export | What it is |
16
16
  | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
17
- | `createJigsClient` | The client, with `observe`, `mutate`, `invalidate` and `session` |
17
+ | `createJigsClient` | The client, with `observe`, `mutate`, `invalidate`, `session`, and `page`, `observePage` and `invalidatePage` for pages |
18
18
  | `httpTransport`, `Transport` | The default HTTP transport, and the type to implement your own |
19
19
  | `TransportError` | What a transport rejects with: `{ code, message }` |
20
20
  | `createViewController`, `ViewController` | One view's page side: `start`, `load`, `subscribe`, `store`, `drawnProps`, `call`, `stop`, and the handlers for issues, actions and confirmation |
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { HeadersOption, Transport } from "./transport.js";
2
- import { Message, MutationResult, QueryResult, SessionEvent, SessionOptions } from "@jigs.design/protocol";
2
+ import { Diagnostic, JigsErrorData, MutationResult, PageProps, Program, QueryResult, SessionBody, SessionEvent } from "@jigs.design/protocol";
3
3
  //#region src/client.d.ts
4
4
  interface CacheOptions {
5
5
  /**
@@ -46,11 +46,46 @@ export type ClientOptions = CacheOptions & ConfirmOptions & ({
46
46
  headers?: never;
47
47
  transport: Transport;
48
48
  });
49
+ /** A page's program and its diagnostics, as the server compiled them. */
50
+ export interface PageData {
51
+ /**
52
+ * A saved page is compiled leniently, so these may hold errors: show a
53
+ * notice beside the program when one is an error or a warning.
54
+ */
55
+ diagnostics: ReadonlyArray<Diagnostic>;
56
+ program: Program;
57
+ }
58
+ /** A page as `observePage` reports it. */
59
+ export interface PageResult {
60
+ /** The program's diagnostics; empty until one arrives. */
61
+ diagnostics: ReadonlyArray<Diagnostic>;
62
+ /** `{ code, message }` when `status` is "error". */
63
+ error: JigsErrorData | null;
64
+ /**
65
+ * The latest program, or null before the first one. A refetch that fails
66
+ * keeps the last program here, beside the error.
67
+ */
68
+ program: Program | null;
69
+ status: "error" | "pending" | "success";
70
+ }
49
71
  export interface JigsClient {
50
72
  /** URL of the sandbox frame document. */
51
73
  readonly frame: string;
74
+ /**
75
+ * Keeps the push channel from closing until the returned function is
76
+ * called; it does not open one. A view holds it from `start` to `stop`,
77
+ * so a program that reads no query for a while, as one still streaming
78
+ * may, does not close and reopen the stream.
79
+ */
80
+ hold: () => () => void;
52
81
  /** Refetches every query with this name, for every input. */
53
82
  invalidate: (name: string) => Promise<void>;
83
+ /**
84
+ * Drops the cached program of page `name`: of every props, or of the
85
+ * props that contain `props`, as a query's input matches. Pages on
86
+ * screen refetch; the rest fetch when next observed.
87
+ */
88
+ invalidatePage: (name: string, props?: PageProps) => Promise<void>;
54
89
  /**
55
90
  * Runs one mutation or action and, when it succeeds, refetches the
56
91
  * queries the server says it refreshed. A reply without that list
@@ -74,11 +109,25 @@ export interface JigsClient {
74
109
  * `kind: "query"`.
75
110
  */
76
111
  observe: (name: string, input: unknown, listener: (result: QueryResult) => void) => () => void;
112
+ /**
113
+ * Keeps one page subscribed and reports every result, as `observe` does
114
+ * for a query. `initial` is a program the page already has, such as one a
115
+ * loader rendered: it fills the cache, so it is not fetched again on
116
+ * mount. Pages are cached by name and props until `invalidatePage` or a
117
+ * reply from a server of another version.
118
+ */
119
+ observePage: (name: string, props: PageProps | undefined, listener: (result: PageResult) => void, options?: {
120
+ initial?: PageData | undefined;
121
+ }) => () => void;
122
+ /**
123
+ * A page's program by name, from the cache when it holds one, else
124
+ * through `POST { type: "page" }`. Rejects with the transport's error.
125
+ */
126
+ page: (name: string, props?: PageProps, options?: {
127
+ signal?: AbortSignal;
128
+ }) => Promise<PageData>;
77
129
  /** One turn, as session events. */
78
- session: (body: {
79
- messages: ReadonlyArray<Message>;
80
- options?: SessionOptions;
81
- }, options: {
130
+ session: (body: SessionBody, options: {
82
131
  signal: AbortSignal;
83
132
  }) => AsyncIterable<SessionEvent>;
84
133
  }
package/dist/client.js CHANGED
@@ -1,10 +1,95 @@
1
1
  import { TransportError, errorDataOf, httpTransport } from "./transport.js";
2
- import { QueryClient, QueryObserver } from "@tanstack/query-core";
2
+ import { QueryClient, QueryObserver, hashKey } from "@tanstack/query-core";
3
3
  //#region src/client.ts
4
4
  /** How long a query result stays fresh by default: a minute. */
5
5
  const DEFAULT_STALE_TIME = 6e4;
6
6
  const RETRYABLE = /* @__PURE__ */ new Set(["NETWORK", "UNAVAILABLE"]);
7
7
  const MAX_RETRIES = 2;
8
+ /**
9
+ * How long the push channel stays open once nothing observes or holds it:
10
+ * a view that remounts, or a block that becomes another, subscribes again
11
+ * well within it.
12
+ */
13
+ const CHANNEL_LINGER_MS = 1e3;
14
+ /** When a channel the server refused or ended is tried again, with queries still observed. */
15
+ const CHANNEL_REOPEN_MS = 3e5;
16
+ /** The refetch after a gap in the channel waits up to this long, so tabs do not all hit the server at once. */
17
+ const GAP_SPREAD_MS = 2e3;
18
+ /**
19
+ * The push channel: opened by the first query or page observed, kept open
20
+ * while any is observed or a view holds it, and its events applied to the
21
+ * cache. It refetches everything on screen only after a gap
22
+ * it cannot account for: a connection lost without a `close` event, to a
23
+ * server that did not replay what it missed.
24
+ */
25
+ function createChannel(transport, handlers) {
26
+ let observers = 0;
27
+ /** Views mounted: they keep an open channel open, but do not open one. */
28
+ let holds = 0;
29
+ let running = null;
30
+ let linger;
31
+ let reopen;
32
+ const consume = async (events, signal) => {
33
+ let first = true;
34
+ let closed = false;
35
+ try {
36
+ for await (const event of events) if (event.type === "invalidate") handlers.apply(event.invalidate);
37
+ else if (event.type === "invalidate-pages") handlers.applyPages(event.pages);
38
+ else if (event.type === "close") closed = true;
39
+ else if (event.type === "open") {
40
+ if (!first && !closed && !event.replayed) setTimeout(handlers.refetchActive, Math.random() * GAP_SPREAD_MS);
41
+ first = false;
42
+ closed = false;
43
+ } else if (event.type === "version") handlers.version(event.version);
44
+ } catch {}
45
+ if (signal.aborted) return;
46
+ running = null;
47
+ clearTimeout(reopen);
48
+ reopen = setTimeout(() => {
49
+ if (observers > 0 && running === null) open();
50
+ }, CHANNEL_REOPEN_MS);
51
+ };
52
+ const open = () => {
53
+ if (transport.events === void 0) return;
54
+ clearTimeout(reopen);
55
+ const controller = new AbortController();
56
+ running = controller;
57
+ consume(transport.events({ signal: controller.signal }), controller.signal);
58
+ };
59
+ /** Closes the channel after the linger, once nothing observes or holds it. */
60
+ const closeWhenIdle = () => {
61
+ if (observers > 0 || holds > 0) return;
62
+ clearTimeout(linger);
63
+ linger = setTimeout(() => {
64
+ clearTimeout(reopen);
65
+ running?.abort();
66
+ running = null;
67
+ }, CHANNEL_LINGER_MS);
68
+ };
69
+ return {
70
+ hold: () => {
71
+ holds += 1;
72
+ clearTimeout(linger);
73
+ let held = true;
74
+ return () => {
75
+ if (held) {
76
+ held = false;
77
+ holds -= 1;
78
+ closeWhenIdle();
79
+ }
80
+ };
81
+ },
82
+ release: () => {
83
+ observers -= 1;
84
+ closeWhenIdle();
85
+ },
86
+ retain: () => {
87
+ observers += 1;
88
+ clearTimeout(linger);
89
+ if (running === null) open();
90
+ }
91
+ };
92
+ }
8
93
  function toQueryResult(result) {
9
94
  if (result.status === "success") return {
10
95
  data: result.data,
@@ -22,6 +107,18 @@ function toQueryResult(result) {
22
107
  status: "pending"
23
108
  };
24
109
  }
110
+ /** A page's key in the program cache: its name, then its props. */
111
+ function pageKey(name, props) {
112
+ return [name, props ?? {}];
113
+ }
114
+ function toPageResult(result) {
115
+ return {
116
+ diagnostics: result.data?.diagnostics ?? [],
117
+ error: result.status === "error" ? errorDataOf(result.error) : null,
118
+ program: result.data?.program ?? null,
119
+ status: result.status
120
+ };
121
+ }
25
122
  /**
26
123
  * The browser's connection to a Jigs server. Create one per app, in its
27
124
  * own module: each client has its own query cache, so two clients share no
@@ -39,9 +136,61 @@ function createJigsClient(options) {
39
136
  staleTime: options.staleTime ?? DEFAULT_STALE_TIME
40
137
  }
41
138
  } });
139
+ const applyInvalidations = (targets) => {
140
+ for (const target of targets) queries.invalidateQueries({ queryKey: target.input === void 0 ? [target.name] : [target.name, target.input] });
141
+ };
142
+ const programs = new QueryClient({ defaultOptions: { queries: {
143
+ gcTime: 3e5,
144
+ refetchOnReconnect: false,
145
+ refetchOnWindowFocus: false,
146
+ retry: (failures, error) => failures < MAX_RETRIES && RETRYABLE.has(errorDataOf(error).code),
147
+ staleTime: Number.POSITIVE_INFINITY
148
+ } } });
149
+ /** The server version the cached programs came from, once one is known. */
150
+ let programsVersion = null;
151
+ /**
152
+ * Notes the server's version. Programs a server of another version
153
+ * compiled are dropped, except `own`'s, the reply that told us: pages on
154
+ * screen refetch, and the rest go.
155
+ */
156
+ const noteVersion = (version, own) => {
157
+ const previous = programsVersion;
158
+ programsVersion = version;
159
+ if (previous === null || previous === version) return;
160
+ const ownHash = own === void 0 ? null : hashKey(own);
161
+ const others = (query) => query.queryHash !== ownHash;
162
+ programs.removeQueries({ predicate: (query) => others(query) && query.getObserversCount() === 0 });
163
+ programs.invalidateQueries({ predicate: others });
164
+ };
165
+ const fetchPage = (name, props) => async (context) => {
166
+ const reply = await transport.page(props === void 0 || Object.keys(props).length === 0 ? { name } : {
167
+ name,
168
+ props
169
+ }, { signal: context.signal });
170
+ noteVersion(reply.version, pageKey(name, props));
171
+ return {
172
+ diagnostics: reply.diagnostics,
173
+ program: reply.program
174
+ };
175
+ };
176
+ /** Pages by name, or by name and the props they contain, as queries match. */
177
+ const invalidatePage = (name, props) => programs.invalidateQueries({ queryKey: props === void 0 ? [name] : [name, props] });
178
+ const channel = createChannel(transport, {
179
+ apply: applyInvalidations,
180
+ applyPages: (pages) => {
181
+ for (const page of pages) invalidatePage(page.name, page.props);
182
+ },
183
+ refetchActive: () => {
184
+ queries.invalidateQueries({ type: "active" });
185
+ programs.invalidateQueries({ type: "active" });
186
+ },
187
+ version: (version) => noteVersion(version)
188
+ });
42
189
  return {
43
190
  frame: transport.frame,
191
+ hold: channel.hold,
44
192
  invalidate: (name) => queries.invalidateQueries({ queryKey: [name] }),
193
+ invalidatePage,
45
194
  mutate: async (name, input, procedure) => {
46
195
  const send = async (confirmation) => {
47
196
  try {
@@ -81,8 +230,7 @@ function createJigsClient(options) {
81
230
  error: unconfirmed,
82
231
  status: "error"
83
232
  };
84
- const targets = reply.invalidate ?? procedure.invalidates.map((target) => ({ name: target }));
85
- for (const target of targets) queries.invalidateQueries({ queryKey: target.input === void 0 ? [target.name] : [target.name, target.input] });
233
+ applyInvalidations(reply.invalidate ?? procedure.invalidates.map((target) => ({ name: target })));
86
234
  return {
87
235
  data: reply.data,
88
236
  status: "success"
@@ -104,8 +252,52 @@ function createJigsClient(options) {
104
252
  const unsubscribe = observer.subscribe((result) => {
105
253
  listener(toQueryResult(result));
106
254
  });
255
+ channel.retain();
107
256
  listener(toQueryResult(observer.getCurrentResult()));
108
- return unsubscribe;
257
+ let subscribed = true;
258
+ return () => {
259
+ if (!subscribed) return;
260
+ subscribed = false;
261
+ unsubscribe();
262
+ channel.release();
263
+ };
264
+ },
265
+ observePage: (name, props, listener, observeOptions) => {
266
+ const initial = observeOptions?.initial;
267
+ const pageOptions = {
268
+ queryFn: fetchPage(name, props),
269
+ queryKey: pageKey(name, props)
270
+ };
271
+ if (initial !== void 0) {
272
+ pageOptions.initialData = initial;
273
+ pageOptions.initialDataUpdatedAt = Date.now();
274
+ }
275
+ const observer = new QueryObserver(programs, pageOptions);
276
+ const unsubscribe = observer.subscribe((result) => {
277
+ listener(toPageResult(result));
278
+ });
279
+ channel.retain();
280
+ listener(toPageResult(observer.getCurrentResult()));
281
+ let subscribed = true;
282
+ return () => {
283
+ if (!subscribed) return;
284
+ subscribed = false;
285
+ unsubscribe();
286
+ channel.release();
287
+ };
288
+ },
289
+ page: (name, props, pageOptions) => {
290
+ const fetched = programs.query({
291
+ queryFn: fetchPage(name, props),
292
+ queryKey: pageKey(name, props)
293
+ });
294
+ const signal = pageOptions?.signal;
295
+ if (signal === void 0) return fetched;
296
+ return Promise.race([fetched, new Promise((_resolve, reject) => {
297
+ const abort = () => reject(signal.reason);
298
+ if (signal.aborted) abort();
299
+ signal.addEventListener("abort", abort, { once: true });
300
+ })]);
109
301
  },
110
302
  session: (body, sessionOptions) => transport.session(body, sessionOptions)
111
303
  };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CallReply, HeadersOption, HttpTransportOptions, Transport, TransportError, httpTransport } from "./transport.js";
2
- import { ClientOptions, ConfirmFunction, JigsClient, createJigsClient } from "./client.js";
2
+ import { ClientOptions, ConfirmFunction, JigsClient, PageData, PageResult, createJigsClient } from "./client.js";
3
3
  import { ActionCall, ActionHandler, ViewController, ViewIssue, ViewStatus, createViewController, missingComponents } from "./view/controller.js";
4
- import { Diagnostic, Invalidation, JigsErrorCode, JigsErrorData, Message, Program, SessionEvent, Severity, Span } from "@jigs.design/protocol";
5
- export { type ActionCall, type ActionHandler, type CallReply, type ClientOptions, type ConfirmFunction, type Diagnostic, type HeadersOption, type HttpTransportOptions, type Invalidation, type JigsClient, type JigsErrorCode, type JigsErrorData, type Message, type Program, type SessionEvent, type Severity, type Span, type Transport, TransportError, type ViewController, type ViewIssue, type ViewStatus, createJigsClient, createViewController, httpTransport, missingComponents };
4
+ import { Diagnostic, Invalidation, JigsErrorCode, JigsErrorData, Message, PageProps, PageReply, Program, PushEvent, SessionBody, SessionEvent, Severity, Span } from "@jigs.design/protocol";
5
+ export { type ActionCall, type ActionHandler, type CallReply, type ClientOptions, type ConfirmFunction, type Diagnostic, type HeadersOption, type HttpTransportOptions, type Invalidation, type JigsClient, type JigsErrorCode, type JigsErrorData, type Message, type PageData, type PageProps, type PageReply, type PageResult, type Program, type PushEvent, type SessionBody, type SessionEvent, type Severity, type Span, type Transport, TransportError, type ViewController, type ViewIssue, type ViewStatus, createJigsClient, createViewController, httpTransport, missingComponents };
@@ -1,4 +1,4 @@
1
- import { Invalidation, JigsErrorCode, JigsErrorData, Message, ProcedureKind, SessionEvent, SessionOptions } from "@jigs.design/protocol";
1
+ import { Invalidation, JigsErrorCode, JigsErrorData, PageProps, PageReply, ProcedureKind, PushEvent, SessionBody, SessionEvent } from "@jigs.design/protocol";
2
2
  //#region src/transport.d.ts
3
3
  /**
4
4
  * A call's reply: the procedure's data and, for a mutation, the queries the
@@ -32,14 +32,34 @@ export interface Transport {
32
32
  }, options: {
33
33
  signal: AbortSignal;
34
34
  }) => Promise<CallReply>;
35
+ /**
36
+ * Opens the push channel and yields its events until `signal` aborts.
37
+ * The transport owns the connection: it reconnects with backoff, resends
38
+ * the last event id, and yields `open` each time a connection is made
39
+ * (then `version`, when it knows the server's). It ends without an abort
40
+ * when the server has no channel or refuses it; the client asks again
41
+ * later. Never throws. Without it, the client has no push.
42
+ */
43
+ events?: (options: {
44
+ signal: AbortSignal;
45
+ }) => AsyncIterable<PushEvent>;
35
46
  /** URL of the sandbox frame document. */
36
47
  frame: string;
37
- /** Reports every failure as one `error` event and then ends; never throws. */
38
- session: (body: {
39
- messages: ReadonlyArray<Message>;
40
- options?: SessionOptions;
48
+ /**
49
+ * A page's compiled program by name, with its diagnostics and the
50
+ * server's version. Rejects with a `TransportError`: `NOT_FOUND` for a
51
+ * page the server will not serve, `BAD_INPUT` for props it refuses. A
52
+ * transport that cannot fetch pages rejects with `UNAVAILABLE`.
53
+ */
54
+ page: (request: {
55
+ name: string;
56
+ props?: PageProps;
41
57
  }, options: {
42
58
  signal: AbortSignal;
59
+ }) => Promise<PageReply>;
60
+ /** Reports every failure as one `error` event and then ends; never throws. */
61
+ session: (body: SessionBody, options: {
62
+ signal: AbortSignal;
43
63
  }) => AsyncIterable<SessionEvent>;
44
64
  }
45
65
  /** A failure with a code the spec can read. */
package/dist/transport.js CHANGED
@@ -1,4 +1,4 @@
1
- import { parseErrorData, parseInvalidations, parseSessionEvent } from "@jigs.design/protocol";
1
+ import { parseErrorData, parseInvalidations, parsePageReply, parsePushEvent, parseSessionEvent } from "@jigs.design/protocol";
2
2
  //#region src/transport.ts
3
3
  /** A failure with a code the spec can read. */
4
4
  var TransportError = class extends Error {
@@ -15,6 +15,74 @@ const GATEWAY_STATUSES = /* @__PURE__ */ new Set([
15
15
  503,
16
16
  504
17
17
  ]);
18
+ /**
19
+ * Push stream answers a reconnect would not change: no channel (204), a
20
+ * refusal (401, 403), or a server that does not know the route (404, 405).
21
+ */
22
+ const FINAL_STREAM_STATUSES = /* @__PURE__ */ new Set([
23
+ 204,
24
+ 401,
25
+ 403,
26
+ 404,
27
+ 405
28
+ ]);
29
+ /** The reconnect delay until the server sends `retry:`, and the most it doubles to. */
30
+ const RETRY_MS = 3e3;
31
+ const MAX_RETRY_MS = 3e4;
32
+ /** A connection that lived this long resets the doubling. */
33
+ const STABLE_MS = 1e4;
34
+ /**
35
+ * The frames of a server-sent event stream, as they complete. Ends when
36
+ * `signal` aborts, even if the body does not.
37
+ */
38
+ async function* sseFrames(body, signal) {
39
+ const reader = body.pipeThrough(new TextDecoderStream()).getReader();
40
+ const stop = () => {
41
+ reader.cancel().catch(() => void 0);
42
+ };
43
+ signal.addEventListener("abort", stop, { once: true });
44
+ let buffer = "";
45
+ try {
46
+ for (;;) {
47
+ const chunk = await reader.read();
48
+ if (chunk.done) return;
49
+ buffer += chunk.value;
50
+ let end = buffer.indexOf("\n\n");
51
+ while (end !== -1) {
52
+ const lines = buffer.slice(0, end).split("\n");
53
+ buffer = buffer.slice(end + 2);
54
+ const field = (name) => lines.filter((line) => line.startsWith(`${name}:`)).map((line) => line.slice(name.length + 1).trimStart());
55
+ const retry = Number(field("retry").at(-1) ?? "NaN");
56
+ yield {
57
+ data: field("data").join("\n"),
58
+ id: field("id").at(-1) ?? null,
59
+ retry: Number.isInteger(retry) && retry >= 0 ? retry : null
60
+ };
61
+ end = buffer.indexOf("\n\n");
62
+ }
63
+ }
64
+ } finally {
65
+ signal.removeEventListener("abort", stop);
66
+ await reader.cancel().catch(() => void 0);
67
+ reader.releaseLock();
68
+ }
69
+ }
70
+ /** Waits `ms`, or less when `signal` aborts. */
71
+ function pause(ms, signal) {
72
+ return new Promise((resolve) => {
73
+ if (signal.aborted) {
74
+ resolve();
75
+ return;
76
+ }
77
+ const done = () => {
78
+ clearTimeout(timer);
79
+ signal.removeEventListener("abort", done);
80
+ resolve();
81
+ };
82
+ const timer = setTimeout(done, ms);
83
+ signal.addEventListener("abort", done, { once: true });
84
+ });
85
+ }
18
86
  const unavailable = {
19
87
  code: "UNAVAILABLE",
20
88
  message: "The server is not available right now."
@@ -33,6 +101,17 @@ async function replyError(response) {
33
101
  return unavailable;
34
102
  }
35
103
  }
104
+ /** A JSON reply's body, or the `TransportError` it carries. */
105
+ async function replyBody(response) {
106
+ if (!response.ok || !response.headers.has(VERSION_HEADER)) throw new TransportError(await replyError(response));
107
+ let body;
108
+ try {
109
+ body = await response.json();
110
+ } catch {
111
+ throw new TransportError(unavailable);
112
+ }
113
+ return typeof body === "object" ? body : null;
114
+ }
36
115
  /** Why the `headers` function failed, as the call's error. */
37
116
  function headersError(cause) {
38
117
  return thrownErrorData(cause) ?? {
@@ -49,7 +128,7 @@ function httpTransport(options) {
49
128
  * token refresh that failed, is not a network failure: it keeps a thrown
50
129
  * `{ code, message }` and is otherwise `UNAUTHORIZED`, never retried.
51
130
  */
52
- const headers = async () => {
131
+ const headers = async (contentType = "application/json") => {
53
132
  let extra;
54
133
  try {
55
134
  extra = typeof options.headers === "function" ? await options.headers() : options.headers;
@@ -57,7 +136,7 @@ function httpTransport(options) {
57
136
  throw new TransportError(headersError(error));
58
137
  }
59
138
  const merged = new Headers(extra);
60
- merged.set("content-type", "application/json");
139
+ if (contentType !== null) merged.set("content-type", contentType);
61
140
  return merged;
62
141
  };
63
142
  const post = (body, init, signal) => fetcher(options.endpoint, {
@@ -67,30 +146,26 @@ function httpTransport(options) {
67
146
  method: "POST",
68
147
  signal
69
148
  });
149
+ /** Posts `body`; a fetch that fails without an abort is `NETWORK`. */
150
+ const send = async (body, signal) => {
151
+ const init = await headers();
152
+ try {
153
+ return await post(body, init, signal);
154
+ } catch (error) {
155
+ if (signal.aborted) throw error;
156
+ throw new TransportError(network);
157
+ }
158
+ };
70
159
  return {
71
160
  async call(request, callOptions) {
72
- const init = await headers();
73
- let response;
74
- try {
75
- response = await post(JSON.stringify({
76
- confirmation: request.confirmation,
77
- input: request.input,
78
- kind: request.kind,
79
- name: request.name,
80
- type: "call"
81
- }), init, callOptions.signal);
82
- } catch (error) {
83
- if (callOptions.signal.aborted) throw error;
84
- throw new TransportError(network);
85
- }
86
- if (!response.ok || !response.headers.has(VERSION_HEADER)) throw new TransportError(await replyError(response));
87
- let body;
88
- try {
89
- body = await response.json();
90
- } catch {
91
- throw new TransportError(unavailable);
92
- }
93
- if (typeof body !== "object" || body === null) throw new TransportError(unavailable);
161
+ const body = await replyBody(await send(JSON.stringify({
162
+ confirmation: request.confirmation,
163
+ input: request.input,
164
+ kind: request.kind,
165
+ name: request.name,
166
+ type: "call"
167
+ }), callOptions.signal));
168
+ if (body === null) throw new TransportError(unavailable);
94
169
  if ("requiresAction" in body) {
95
170
  const requiresAction = parseRequiresAction(body.requiresAction);
96
171
  if (requiresAction === null) throw new TransportError(unavailable);
@@ -105,7 +180,79 @@ function httpTransport(options) {
105
180
  invalidate
106
181
  };
107
182
  },
183
+ async *events({ signal }) {
184
+ let lastId = null;
185
+ let retryMs = RETRY_MS;
186
+ let failures = 0;
187
+ while (!signal.aborted) {
188
+ let init;
189
+ try {
190
+ init = await headers(null);
191
+ } catch {
192
+ return;
193
+ }
194
+ init.set("accept", "text/event-stream");
195
+ const resumed = lastId !== null;
196
+ if (lastId !== null) init.set("last-event-id", lastId);
197
+ let response = null;
198
+ try {
199
+ response = await fetcher(options.endpoint, {
200
+ cache: "no-store",
201
+ credentials,
202
+ headers: init,
203
+ method: "GET",
204
+ signal
205
+ });
206
+ } catch {}
207
+ if (signal.aborted) return;
208
+ const type = response?.headers.get("content-type") ?? "";
209
+ if (response !== null && (FINAL_STREAM_STATUSES.has(response.status) || type.toLowerCase().startsWith("text/html"))) {
210
+ await response.body?.cancel().catch(() => void 0);
211
+ return;
212
+ }
213
+ const version = response?.headers.get(VERSION_HEADER) ?? null;
214
+ const opened = Date.now();
215
+ let closed = false;
216
+ if (response !== null && response.ok && version !== null && response.body !== null && type.toLowerCase().startsWith("text/event-stream")) {
217
+ yield {
218
+ replayed: resumed,
219
+ type: "open"
220
+ };
221
+ yield {
222
+ type: "version",
223
+ version
224
+ };
225
+ try {
226
+ for await (const frame of sseFrames(response.body, signal)) {
227
+ if (frame.retry !== null) retryMs = frame.retry;
228
+ if (frame.id !== null) lastId = frame.id === "" ? null : frame.id;
229
+ const event = frame.data === "" ? null : parsePushEvent(frame.data);
230
+ if (event !== null) {
231
+ closed ||= event.type === "close";
232
+ yield event;
233
+ }
234
+ }
235
+ } catch {}
236
+ } else await response?.body?.cancel().catch(() => void 0);
237
+ if (closed || Date.now() - opened >= STABLE_MS) failures = 0;
238
+ else failures += 1;
239
+ await pause(Math.min(retryMs * 2 ** Math.max(0, failures - 1), MAX_RETRY_MS), signal);
240
+ }
241
+ },
108
242
  frame: options.endpoint,
243
+ async page(request, pageOptions) {
244
+ const response = await send(JSON.stringify(request.props === void 0 ? {
245
+ name: request.name,
246
+ type: "page"
247
+ } : {
248
+ name: request.name,
249
+ props: request.props,
250
+ type: "page"
251
+ }), pageOptions.signal);
252
+ const reply = parsePageReply(await replyBody(response));
253
+ if (reply === null) throw new TransportError(unavailable);
254
+ return reply;
255
+ },
109
256
  async *session(body, { signal }) {
110
257
  let init;
111
258
  try {
@@ -120,6 +267,7 @@ function httpTransport(options) {
120
267
  let response;
121
268
  try {
122
269
  response = await post(JSON.stringify({
270
+ document: body.document,
123
271
  messages: body.messages,
124
272
  options: body.options,
125
273
  type: "session"
@@ -138,40 +286,24 @@ function httpTransport(options) {
138
286
  };
139
287
  return;
140
288
  }
141
- const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
142
- let buffer = "";
143
289
  let ended = false;
144
290
  try {
145
- for (;;) {
146
- const chunk = await reader.read();
147
- if (chunk.done) {
148
- if (!ended && !signal.aborted) yield {
149
- error: network,
150
- type: "error"
151
- };
152
- return;
153
- }
154
- buffer += chunk.value;
155
- let end = buffer.indexOf("\n\n");
156
- while (end !== -1) {
157
- const frame = buffer.slice(0, end);
158
- buffer = buffer.slice(end + 2);
159
- const data = frame.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
160
- const event = data === "" ? null : parseSessionEvent(data);
161
- if (event !== null) {
162
- ended ||= event.type !== "program";
163
- yield event;
164
- }
165
- end = buffer.indexOf("\n\n");
291
+ for await (const frame of sseFrames(response.body, signal)) {
292
+ const event = frame.data === "" ? null : parseSessionEvent(frame.data);
293
+ if (event !== null) {
294
+ ended ||= event.type !== "program";
295
+ yield event;
166
296
  }
167
297
  }
298
+ if (!ended && !signal.aborted) yield {
299
+ error: network,
300
+ type: "error"
301
+ };
168
302
  } catch {
169
303
  if (!signal.aborted) yield {
170
304
  error: network,
171
305
  type: "error"
172
306
  };
173
- } finally {
174
- reader.releaseLock();
175
307
  }
176
308
  }
177
309
  };
@@ -1,5 +1,5 @@
1
1
  import { ConfirmFunction, JigsClient } from "../client.js";
2
- import { Program, PropSchema, RuntimeError, WireArg } from "@jigs.design/protocol";
2
+ import { JsonValue, Program, PropSchema, RuntimeError, WireArg } from "@jigs.design/protocol";
3
3
  import { TreeNode, TreeStore } from "@jigs.design/runtime";
4
4
  //#region src/view/controller.d.ts
5
5
  /** What the view shows beside the tree: a snapshot, replaced on each change. */
@@ -91,6 +91,12 @@ export interface ViewController {
91
91
  * `signal` aborted.
92
92
  */
93
93
  setConfirmHandler: (handler: ConfirmFunction | undefined) => void;
94
+ /**
95
+ * Replaces the provided data, by slot name. Every read of a slot whose
96
+ * value changed, compared as JSON, gets the new value, as a refetched
97
+ * query's does; a slot left out reads as `UNAVAILABLE`.
98
+ */
99
+ setData: (values: Readonly<Record<string, JsonValue>> | undefined) => void;
94
100
  /** Replaces what each new issue is given to; `undefined` stops reporting. */
95
101
  setIssueHandler: (handler: ((issue: ViewIssue) => void) | undefined) => void;
96
102
  /**
@@ -35,6 +35,15 @@ const NO_ACTIONS = {
35
35
  },
36
36
  status: "error"
37
37
  };
38
+ /** What a read of slot `name` gets when the page provides no value for it. */
39
+ const notProvided = (name) => ({
40
+ data: null,
41
+ error: {
42
+ code: "UNAVAILABLE",
43
+ message: `The page provided no "${name}"`
44
+ },
45
+ status: "error"
46
+ });
38
47
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
39
48
  /** The runtime errors of a result, as text React can draw. The frame is not trusted. */
40
49
  function parseErrors(list) {
@@ -73,6 +82,10 @@ function createViewController(client) {
73
82
  const store = createTreeStore();
74
83
  const listeners = /* @__PURE__ */ new Set();
75
84
  const observers = /* @__PURE__ */ new Map();
85
+ /** Reads of provided slots, answered here, never fetched: key to slot name. */
86
+ const slotReads = /* @__PURE__ */ new Map();
87
+ /** The page's provided data, each value with its JSON to compare by. */
88
+ let provided = /* @__PURE__ */ new Map();
76
89
  const sent = /* @__PURE__ */ new Map();
77
90
  let iframe = null;
78
91
  let ready = false;
@@ -107,6 +120,8 @@ function createViewController(client) {
107
120
  * still showing cannot run a mutation for a program that is gone.
108
121
  */
109
122
  let confirmationScope = new AbortController();
123
+ /** Lets the push channel close; set while the view is started. */
124
+ let unhold = null;
110
125
  const withdrawConfirmations = () => {
111
126
  confirmationScope.abort();
112
127
  confirmationScope = new AbortController();
@@ -241,8 +256,18 @@ function createViewController(client) {
241
256
  const dropObservers = () => {
242
257
  for (const unsubscribe of observers.values()) unsubscribe();
243
258
  observers.clear();
259
+ slotReads.clear();
244
260
  sent.clear();
245
261
  };
262
+ /** What a read of slot `name` gets now. */
263
+ const slotResult = (name) => {
264
+ const entry = provided.get(name);
265
+ return entry === void 0 ? notProvided(name) : {
266
+ data: entry.value,
267
+ error: null,
268
+ status: "success"
269
+ };
270
+ };
246
271
  /** The running program's entry for `name`, if it is one of `kinds`. */
247
272
  const procedure = (name, kinds) => {
248
273
  if (running === null || !Object.hasOwn(running.procedures, name)) return;
@@ -253,7 +278,7 @@ function createViewController(client) {
253
278
  const checkedReads = (reads) => {
254
279
  const wanted = /* @__PURE__ */ new Map();
255
280
  for (const read of reads) {
256
- if (!isRecord(read) || typeof read.key !== "string" || typeof read.name !== "string" || procedure(read.name, ["query"]) === void 0) {
281
+ if (!isRecord(read) || typeof read.key !== "string" || typeof read.name !== "string" || procedure(read.name, ["data", "query"]) === void 0) {
257
282
  drop("The sandbox read a query this program does not declare");
258
283
  continue;
259
284
  }
@@ -275,9 +300,20 @@ function createViewController(client) {
275
300
  observers.delete(key);
276
301
  sent.delete(key);
277
302
  }
278
- for (const [key, read] of wanted) if (!observers.has(key)) observers.set(key, client.observe(read.name, read.input, (result) => {
279
- sendQuery(key, result);
280
- }));
303
+ for (const key of slotReads.keys()) if (!wanted.has(key)) {
304
+ slotReads.delete(key);
305
+ sent.delete(key);
306
+ }
307
+ for (const [key, read] of wanted) {
308
+ if (procedure(read.name, ["data"]) !== void 0) {
309
+ slotReads.set(key, read.name);
310
+ sendQuery(key, slotResult(read.name));
311
+ continue;
312
+ }
313
+ if (!observers.has(key)) observers.set(key, client.observe(read.name, read.input, (result) => {
314
+ sendQuery(key, result);
315
+ }));
316
+ }
281
317
  };
282
318
  /** Marks the call `answered` done; returns whether it was a handler call. */
283
319
  const answer = (answered) => {
@@ -478,6 +514,19 @@ function createViewController(client) {
478
514
  setConfirmHandler: (handler) => {
479
515
  confirmHandler = handler;
480
516
  },
517
+ setData: (values) => {
518
+ const next = /* @__PURE__ */ new Map();
519
+ for (const [name, value] of Object.entries(values ?? {})) {
520
+ const text = JSON.stringify(value);
521
+ const before = provided.get(name);
522
+ next.set(name, before?.text === text ? before : {
523
+ text,
524
+ value
525
+ });
526
+ }
527
+ provided = next;
528
+ for (const [key, name] of slotReads) sendQuery(key, slotResult(name));
529
+ },
481
530
  setIssueHandler: (handler) => {
482
531
  issueHandler = handler;
483
532
  },
@@ -497,6 +546,7 @@ function createViewController(client) {
497
546
  if (iframe === frame && !ready && startup === null) startup = setTimeout(diagnoseFrame, STARTUP_MS);
498
547
  });
499
548
  iframe = frame;
549
+ unhold = client.hold();
500
550
  window.addEventListener("message", onMessage);
501
551
  document.body.append(frame);
502
552
  },
@@ -520,8 +570,11 @@ function createViewController(client) {
520
570
  latestDocument = void 0;
521
571
  actionHandler = void 0;
522
572
  confirmHandler = void 0;
573
+ provided = /* @__PURE__ */ new Map();
523
574
  withdrawConfirmations();
524
575
  dropObservers();
576
+ unhold?.();
577
+ unhold = null;
525
578
  store.reset();
526
579
  status = idle;
527
580
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jigs.design/client",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "createJigsClient: the browser's transport and query cache. No React.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@tanstack/query-core": "5.104.4",
29
- "@jigs.design/protocol": "0.2.0",
30
- "@jigs.design/runtime": "0.2.0"
29
+ "@jigs.design/runtime": "0.3.1",
30
+ "@jigs.design/protocol": "0.3.1"
31
31
  }
32
32
  }