@jigs.design/client 0.1.2 → 0.3.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
@@ -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 |
@@ -26,7 +26,7 @@ The view calls `observe` and `mutate`; app code calls `invalidate`. `mutate(name
26
26
 
27
27
  The controller trusts the program it loaded and nothing the worker says: it checks each read and mutation against the program's procedure table, has the tree store check each op against the program's prop schemas, and enforces the budgets on the page again. `drawnProps` turns a node's props into what a component takes, reading each prop's kind from the schema, never from the value.
28
28
 
29
- It re-exports the shared types from `@jigs.design/runtime`.
29
+ It re-exports the shared types from `@jigs.design/protocol`.
30
30
 
31
31
  ## Source
32
32
 
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/runtime";
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,39 @@ 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;
52
74
  /** Refetches every query with this name, for every input. */
53
75
  invalidate: (name: string) => Promise<void>;
76
+ /**
77
+ * Drops the cached program of page `name`: of every props, or of the
78
+ * props that contain `props`, as a query's input matches. Pages on
79
+ * screen refetch; the rest fetch when next observed.
80
+ */
81
+ invalidatePage: (name: string, props?: PageProps) => Promise<void>;
54
82
  /**
55
83
  * Runs one mutation or action and, when it succeeds, refetches the
56
84
  * queries the server says it refreshed. A reply without that list
@@ -74,11 +102,25 @@ export interface JigsClient {
74
102
  * `kind: "query"`.
75
103
  */
76
104
  observe: (name: string, input: unknown, listener: (result: QueryResult) => void) => () => void;
105
+ /**
106
+ * Keeps one page subscribed and reports every result, as `observe` does
107
+ * for a query. `initial` is a program the page already has, such as one a
108
+ * loader rendered: it fills the cache, so it is not fetched again on
109
+ * mount. Pages are cached by name and props until `invalidatePage` or a
110
+ * reply from a server of another version.
111
+ */
112
+ observePage: (name: string, props: PageProps | undefined, listener: (result: PageResult) => void, options?: {
113
+ initial?: PageData | undefined;
114
+ }) => () => void;
115
+ /**
116
+ * A page's program by name, from the cache when it holds one, else
117
+ * through `POST { type: "page" }`. Rejects with the transport's error.
118
+ */
119
+ page: (name: string, props?: PageProps, options?: {
120
+ signal?: AbortSignal;
121
+ }) => Promise<PageData>;
77
122
  /** One turn, as session events. */
78
- session: (body: {
79
- messages: ReadonlyArray<Message>;
80
- options?: SessionOptions;
81
- }, options: {
123
+ session: (body: SessionBody, options: {
82
124
  signal: AbortSignal;
83
125
  }) => AsyncIterable<SessionEvent>;
84
126
  }
package/dist/client.js CHANGED
@@ -1,10 +1,76 @@
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 after the last query unsubscribes:
10
+ * a program swap re-creates its observers after a round trip to the
11
+ * sandbox, not within a tick.
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: open while any query or page is observed, and its events
20
+ * applied to the cache. It refetches everything on screen only after a gap
21
+ * it cannot account for: a connection lost without a `close` event, to a
22
+ * server that did not replay what it missed.
23
+ */
24
+ function createChannel(transport, handlers) {
25
+ let observers = 0;
26
+ let running = null;
27
+ let linger;
28
+ let reopen;
29
+ const consume = async (events, signal) => {
30
+ let first = true;
31
+ let closed = false;
32
+ try {
33
+ for await (const event of events) if (event.type === "invalidate") handlers.apply(event.invalidate);
34
+ else if (event.type === "invalidate-pages") handlers.applyPages(event.pages);
35
+ else if (event.type === "close") closed = true;
36
+ else if (event.type === "open") {
37
+ if (!first && !closed && !event.replayed) setTimeout(handlers.refetchActive, Math.random() * GAP_SPREAD_MS);
38
+ first = false;
39
+ closed = false;
40
+ } else if (event.type === "version") handlers.version(event.version);
41
+ } catch {}
42
+ if (signal.aborted) return;
43
+ running = null;
44
+ clearTimeout(reopen);
45
+ reopen = setTimeout(() => {
46
+ if (observers > 0 && running === null) open();
47
+ }, CHANNEL_REOPEN_MS);
48
+ };
49
+ const open = () => {
50
+ if (transport.events === void 0) return;
51
+ clearTimeout(reopen);
52
+ const controller = new AbortController();
53
+ running = controller;
54
+ consume(transport.events({ signal: controller.signal }), controller.signal);
55
+ };
56
+ return {
57
+ release: () => {
58
+ observers -= 1;
59
+ if (observers > 0) return;
60
+ clearTimeout(linger);
61
+ linger = setTimeout(() => {
62
+ clearTimeout(reopen);
63
+ running?.abort();
64
+ running = null;
65
+ }, CHANNEL_LINGER_MS);
66
+ },
67
+ retain: () => {
68
+ observers += 1;
69
+ clearTimeout(linger);
70
+ if (running === null) open();
71
+ }
72
+ };
73
+ }
8
74
  function toQueryResult(result) {
9
75
  if (result.status === "success") return {
10
76
  data: result.data,
@@ -22,6 +88,18 @@ function toQueryResult(result) {
22
88
  status: "pending"
23
89
  };
24
90
  }
91
+ /** A page's key in the program cache: its name, then its props. */
92
+ function pageKey(name, props) {
93
+ return [name, props ?? {}];
94
+ }
95
+ function toPageResult(result) {
96
+ return {
97
+ diagnostics: result.data?.diagnostics ?? [],
98
+ error: result.status === "error" ? errorDataOf(result.error) : null,
99
+ program: result.data?.program ?? null,
100
+ status: result.status
101
+ };
102
+ }
25
103
  /**
26
104
  * The browser's connection to a Jigs server. Create one per app, in its
27
105
  * own module: each client has its own query cache, so two clients share no
@@ -39,9 +117,60 @@ function createJigsClient(options) {
39
117
  staleTime: options.staleTime ?? DEFAULT_STALE_TIME
40
118
  }
41
119
  } });
120
+ const applyInvalidations = (targets) => {
121
+ for (const target of targets) queries.invalidateQueries({ queryKey: target.input === void 0 ? [target.name] : [target.name, target.input] });
122
+ };
123
+ const programs = new QueryClient({ defaultOptions: { queries: {
124
+ gcTime: 3e5,
125
+ refetchOnReconnect: false,
126
+ refetchOnWindowFocus: false,
127
+ retry: (failures, error) => failures < MAX_RETRIES && RETRYABLE.has(errorDataOf(error).code),
128
+ staleTime: Number.POSITIVE_INFINITY
129
+ } } });
130
+ /** The server version the cached programs came from, once one is known. */
131
+ let programsVersion = null;
132
+ /**
133
+ * Notes the server's version. Programs a server of another version
134
+ * compiled are dropped, except `own`'s, the reply that told us: pages on
135
+ * screen refetch, and the rest go.
136
+ */
137
+ const noteVersion = (version, own) => {
138
+ const previous = programsVersion;
139
+ programsVersion = version;
140
+ if (previous === null || previous === version) return;
141
+ const ownHash = own === void 0 ? null : hashKey(own);
142
+ const others = (query) => query.queryHash !== ownHash;
143
+ programs.removeQueries({ predicate: (query) => others(query) && query.getObserversCount() === 0 });
144
+ programs.invalidateQueries({ predicate: others });
145
+ };
146
+ const fetchPage = (name, props) => async (context) => {
147
+ const reply = await transport.page(props === void 0 || Object.keys(props).length === 0 ? { name } : {
148
+ name,
149
+ props
150
+ }, { signal: context.signal });
151
+ noteVersion(reply.version, pageKey(name, props));
152
+ return {
153
+ diagnostics: reply.diagnostics,
154
+ program: reply.program
155
+ };
156
+ };
157
+ /** Pages by name, or by name and the props they contain, as queries match. */
158
+ const invalidatePage = (name, props) => programs.invalidateQueries({ queryKey: props === void 0 ? [name] : [name, props] });
159
+ const channel = createChannel(transport, {
160
+ apply: applyInvalidations,
161
+ applyPages: (pages) => {
162
+ for (const page of pages) invalidatePage(page.name, page.props);
163
+ },
164
+ refetchActive: () => {
165
+ queries.invalidateQueries({ type: "active" });
166
+ programs.invalidateQueries({ type: "active" });
167
+ },
168
+ version: (version) => noteVersion(version)
169
+ });
42
170
  return {
43
171
  frame: transport.frame,
44
172
  invalidate: (name) => queries.invalidateQueries({ queryKey: [name] }),
173
+ invalidatePage,
45
174
  mutate: async (name, input, procedure) => {
46
175
  const send = async (confirmation) => {
47
176
  try {
@@ -81,8 +210,7 @@ function createJigsClient(options) {
81
210
  error: unconfirmed,
82
211
  status: "error"
83
212
  };
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] });
213
+ applyInvalidations(reply.invalidate ?? procedure.invalidates.map((target) => ({ name: target })));
86
214
  return {
87
215
  data: reply.data,
88
216
  status: "success"
@@ -104,8 +232,52 @@ function createJigsClient(options) {
104
232
  const unsubscribe = observer.subscribe((result) => {
105
233
  listener(toQueryResult(result));
106
234
  });
235
+ channel.retain();
107
236
  listener(toQueryResult(observer.getCurrentResult()));
108
- return unsubscribe;
237
+ let subscribed = true;
238
+ return () => {
239
+ if (!subscribed) return;
240
+ subscribed = false;
241
+ unsubscribe();
242
+ channel.release();
243
+ };
244
+ },
245
+ observePage: (name, props, listener, observeOptions) => {
246
+ const initial = observeOptions?.initial;
247
+ const pageOptions = {
248
+ queryFn: fetchPage(name, props),
249
+ queryKey: pageKey(name, props)
250
+ };
251
+ if (initial !== void 0) {
252
+ pageOptions.initialData = initial;
253
+ pageOptions.initialDataUpdatedAt = Date.now();
254
+ }
255
+ const observer = new QueryObserver(programs, pageOptions);
256
+ const unsubscribe = observer.subscribe((result) => {
257
+ listener(toPageResult(result));
258
+ });
259
+ channel.retain();
260
+ listener(toPageResult(observer.getCurrentResult()));
261
+ let subscribed = true;
262
+ return () => {
263
+ if (!subscribed) return;
264
+ subscribed = false;
265
+ unsubscribe();
266
+ channel.release();
267
+ };
268
+ },
269
+ page: (name, props, pageOptions) => {
270
+ const fetched = programs.query({
271
+ queryFn: fetchPage(name, props),
272
+ queryKey: pageKey(name, props)
273
+ });
274
+ const signal = pageOptions?.signal;
275
+ if (signal === void 0) return fetched;
276
+ return Promise.race([fetched, new Promise((_resolve, reject) => {
277
+ const abort = () => reject(signal.reason);
278
+ if (signal.aborted) abort();
279
+ signal.addEventListener("abort", abort, { once: true });
280
+ })]);
109
281
  },
110
282
  session: (body, sessionOptions) => transport.session(body, sessionOptions)
111
283
  };
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, Span } from "@jigs.design/runtime";
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 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/runtime";
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/runtime";
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,6 @@
1
1
  import { ConfirmFunction, JigsClient } from "../client.js";
2
- import { Program, PropSchema, RuntimeError, TreeNode, TreeStore, WireArg } from "@jigs.design/runtime";
2
+ import { Program, PropSchema, RuntimeError, WireArg } from "@jigs.design/protocol";
3
+ import { TreeNode, TreeStore } from "@jigs.design/runtime";
3
4
  //#region src/view/controller.d.ts
4
5
  /** What the view shows beside the tree: a snapshot, replaced on each change. */
5
6
  export interface ViewStatus {
@@ -1,5 +1,6 @@
1
1
  import { frameProblem, unprotectedProblem } from "./frame-check.js";
2
- import { createTreeStore, parseErrorData, parseProgram } from "@jigs.design/runtime";
2
+ import { parseErrorData, parseProgram } from "@jigs.design/protocol";
3
+ import { createTreeStore } from "@jigs.design/runtime";
3
4
  //#region src/view/controller.ts
4
5
  const WATCHDOG_NOTICE = "Program stopped: it did not answer within a second. The sandbox was restarted.";
5
6
  const GAVE_UP_NOTICE = "Program stopped: it did not answer within a second, again after a restart. It will not run until a new program is loaded.";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jigs.design/client",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "createJigsClient: the browser's transport and query cache. No React.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,6 +26,7 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@tanstack/query-core": "5.104.4",
29
- "@jigs.design/runtime": "0.1.2"
29
+ "@jigs.design/protocol": "0.3.0",
30
+ "@jigs.design/runtime": "0.3.0"
30
31
  }
31
32
  }