@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 +3 -3
- package/dist/client.d.ts +47 -5
- package/dist/client.js +176 -4
- package/dist/index.d.ts +3 -3
- package/dist/transport.d.ts +25 -5
- package/dist/transport.js +182 -50
- package/dist/view/controller.d.ts +2 -1
- package/dist/view/controller.js +2 -1
- package/package.json +3 -2
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 `
|
|
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/
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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 };
|
package/dist/transport.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Invalidation, JigsErrorCode, JigsErrorData,
|
|
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
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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/
|
|
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",
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
147
|
-
if (
|
|
148
|
-
|
|
149
|
-
|
|
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,
|
|
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 {
|
package/dist/view/controller.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { frameProblem, unprotectedProblem } from "./frame-check.js";
|
|
2
|
-
import {
|
|
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.
|
|
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/
|
|
29
|
+
"@jigs.design/protocol": "0.3.0",
|
|
30
|
+
"@jigs.design/runtime": "0.3.0"
|
|
30
31
|
}
|
|
31
32
|
}
|