@jigs.design/client 0.1.0 → 0.1.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
@@ -1,6 +1,6 @@
1
1
  # @jigs.design/client
2
2
 
3
- `createPickleClient` is the browser's connection to a Pickle server: the transport and the query cache, on TanStack Query Core. It has no React and no schema library.
3
+ `createPickleClient` is the browser's connection to a Pickle server: the transport and the query cache, on TanStack Query Core. `createViewController` is the framework-free half of the view: it owns the sandbox frame, keeps the tree and routes the program's queries, mutations and actions through the client, so a renderer for any framework builds on it as `PickleView` does. The package has no React and no schema library.
4
4
 
5
5
  ```ts
6
6
  import { createPickleClient } from "@jigs.design/client";
@@ -12,13 +12,19 @@ Create one client per app, in its own module. Every view and session that import
12
12
 
13
13
  ## Exports
14
14
 
15
- | Export | What it is |
16
- | -------------------- | ---------------------------------------------------------------- |
17
- | `createPickleClient` | The client, with `observe`, `mutate`, `invalidate` and `session` |
15
+ | Export | What it is |
16
+ | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
17
+ | `createPickleClient` | The client, with `observe`, `mutate`, `invalidate` and `session` |
18
+ | `httpTransport`, `Transport` | The default HTTP transport, and the type to implement your own |
19
+ | `TransportError` | What a transport rejects with: `{ code, message }` |
20
+ | `createViewController`, `ViewController` | One view's page side: `start`, `load`, `subscribe`, `store`, `drawnProps`, `call`, `stop`, and the handlers for issues, actions and confirmation |
21
+ | `missingComponents` | The components a program draws that a registry lacks, as issues for `setUnregistered` |
22
+ | `ViewIssue`, `ViewStatus` | What the issue handler receives, and what `getStatus` returns |
23
+ | `ActionCall`, `ActionHandler`, `ConfirmFunction` | What the action and confirmation handlers receive |
18
24
 
19
25
  The view calls `observe` and `mutate`; app code calls `invalidate`. `mutate(name, input, procedure)` takes the procedure's `kind` and `invalidates` from the program, and every call goes out with its `kind`, which the server checks against the host.
20
- | `httpTransport`, `Transport` | The default HTTP transport, and the type to implement your own |
21
- | `TransportError` | What a transport rejects with: `{ code, message }` |
26
+
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.
22
28
 
23
29
  It re-exports the shared types from `@jigs.design/runtime`.
24
30
 
@@ -26,7 +32,10 @@ It re-exports the shared types from `@jigs.design/runtime`.
26
32
 
27
33
  - `src/client.ts`: the client and its query cache.
28
34
  - `src/transport.ts`: the transport contract and the HTTP transport.
35
+ - `src/view/controller.ts`: the frame, its messages, the tree and the calls it makes through the client.
36
+ - `src/view/frame-check.ts`: tells you when a page policy broke the frame's own.
37
+ - `src/view/protocol-reference.test.ts`: renders the frame protocol page of the docs from the types and the limits.
29
38
 
30
39
  ## Docs
31
40
 
32
- [Transport](../../apps/docs/docs/running/transport.mdx) and [Session](../../apps/docs/docs/running/session.mdx).
41
+ [Transport](../../apps/docs/docs/running/transport.mdx), [Session](../../apps/docs/docs/running/session.mdx), [Renderers](../../apps/docs/docs/renderers/index.mdx) and [Frame protocol](../../apps/docs/docs/renderers/protocol.mdx).
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CallReply, HeadersOption, HttpTransportOptions, Transport, TransportError, httpTransport } from "./transport.js";
2
2
  import { ClientOptions, ConfirmFunction, PickleClient, createPickleClient } from "./client.js";
3
+ import { ActionCall, ActionHandler, ViewController, ViewIssue, ViewStatus, createViewController, missingComponents } from "./view/controller.js";
3
4
  import { Diagnostic, Invalidation, Message, PickleErrorCode, PickleErrorData, Program, SessionEvent, Span } from "@jigs.design/runtime";
4
- export { type CallReply, type ClientOptions, type ConfirmFunction, type Diagnostic, type HeadersOption, type HttpTransportOptions, type Invalidation, type Message, type PickleClient, type PickleErrorCode, type PickleErrorData, type Program, type SessionEvent, type Span, type Transport, TransportError, createPickleClient, httpTransport };
5
+ export { type ActionCall, type ActionHandler, type CallReply, type ClientOptions, type ConfirmFunction, type Diagnostic, type HeadersOption, type HttpTransportOptions, type Invalidation, type Message, type PickleClient, type PickleErrorCode, type PickleErrorData, type Program, type SessionEvent, type Span, type Transport, TransportError, type ViewController, type ViewIssue, type ViewStatus, createPickleClient, createViewController, httpTransport, missingComponents };
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  import { TransportError, httpTransport } from "./transport.js";
2
2
  import { createPickleClient } from "./client.js";
3
- export { TransportError, createPickleClient, httpTransport };
3
+ import { createViewController, missingComponents } from "./view/controller.js";
4
+ export { TransportError, createPickleClient, createViewController, httpTransport, missingComponents };
@@ -0,0 +1,132 @@
1
+ import { ConfirmFunction, PickleClient } from "../client.js";
2
+ import { Program, PropSchema, RuntimeError, TreeNode, TreeStore, WireArg } from "@jigs.design/runtime";
3
+ //#region src/view/controller.d.ts
4
+ /** What the view shows beside the tree: a snapshot, replaced on each change. */
5
+ export interface ViewStatus {
6
+ /** Runtime errors the program threw in its last result. */
7
+ errors: ReadonlyArray<RuntimeError>;
8
+ /** Props and components the view dropped while checking ops. */
9
+ issues: ReadonlyArray<string>;
10
+ /** Set when the sandbox rejected or stopped a program, or cannot run. */
11
+ notice: string | null;
12
+ }
13
+ /**
14
+ * A problem the view found, as `onIssue` receives it: a runtime error the
15
+ * program threw, something the view dropped (a prop or component that failed
16
+ * its schema, a request the program does not declare, a component missing
17
+ * from the registry), or a notice that the sandbox rejected or stopped a
18
+ * program or cannot run.
19
+ */
20
+ export type ViewIssue = {
21
+ kind: "dropped";
22
+ message: string;
23
+ } | {
24
+ kind: "error";
25
+ line: number | null;
26
+ message: string;
27
+ } | {
28
+ kind: "notice";
29
+ message: string;
30
+ };
31
+ /** An action the program called, after the server checked its input. */
32
+ export interface ActionCall {
33
+ input: unknown;
34
+ name: string;
35
+ }
36
+ /**
37
+ * Handles the program's actions. What it returns (awaited and copied with
38
+ * `structuredClone`) is the handle's `data`; what it throws is the handle's
39
+ * `error`.
40
+ */
41
+ export type ActionHandler = (action: ActionCall) => unknown;
42
+ /**
43
+ * The page side of one view, for any renderer: it owns the sandbox frame and
44
+ * the tree, and routes the program's queries, mutations and actions. Call
45
+ * `start`, then `load` a program; `subscribe` fires when the tree or the
46
+ * status changes, and `store.get` reads a node from `ROOT_ID` down. Draw
47
+ * each node's props through `drawnProps`, which gives handlers as functions
48
+ * that call back into the worker. `stop` tears everything down.
49
+ */
50
+ export interface ViewController {
51
+ /**
52
+ * Runs handler `fn` of node `node` in the worker with `args`. The
53
+ * functions `drawnProps` returns call this; use it directly to replay an
54
+ * event from elsewhere.
55
+ */
56
+ call: (node: number, fn: string, args: ReadonlyArray<WireArg>) => void;
57
+ /** Whether the current program lets `type` take children. */
58
+ childrenAllowed: (type: string) => boolean;
59
+ /**
60
+ * The props to draw `node` with: a primitive prop as it is, a list as its
61
+ * rows, and a handler as a function that sends its primitive arguments to
62
+ * the worker. Which is which comes from the program's schema, never from
63
+ * the value. A prop named `autoFocus` is left out unless `allowAutoFocus`,
64
+ * so a program cannot take the focus on a page that has not allowed it.
65
+ */
66
+ drawnProps: (node: TreeNode, options: {
67
+ allowAutoFocus: boolean;
68
+ }) => Record<string, unknown>;
69
+ /**
70
+ * How many times the worker finished answering every handler call of
71
+ * `node`. It changes once the program has seen the node's latest events.
72
+ */
73
+ getSettled: (node: number) => number;
74
+ getStatus: () => ViewStatus;
75
+ /**
76
+ * Runs `program`. When `document` differs from the last one's, the
77
+ * program starts from fresh state and confirmations still waiting on the
78
+ * user are withdrawn; otherwise state it still declares keeps its value,
79
+ * and an equal program, by value, does nothing.
80
+ */
81
+ load: (program: Program, document?: number | string) => void;
82
+ /** The kind of prop `name` of `type` in the current program's schema. */
83
+ propKind: (type: string, name: string) => PropSchema["kind"] | undefined;
84
+ /** Replaces the action handler; `undefined` makes every action fail. */
85
+ setActionHandler: (handler: ActionHandler | undefined) => void;
86
+ /**
87
+ * Replaces what asks the user to confirm a mutation; `undefined` falls
88
+ * back to the client's `confirm`. A question still open when the view
89
+ * stops, loads another document or restarts a hung worker has its
90
+ * `signal` aborted.
91
+ */
92
+ setConfirmHandler: (handler: ConfirmFunction | undefined) => void;
93
+ /** Replaces what each new issue is given to; `undefined` stops reporting. */
94
+ setIssueHandler: (handler: ((issue: ViewIssue) => void) | undefined) => void;
95
+ /**
96
+ * Components the program uses that the registry lacks, as issues to
97
+ * report; `missingComponents` lists them.
98
+ */
99
+ setUnregistered: (issues: ReadonlyArray<string>) => void;
100
+ /**
101
+ * Appends the hidden sandbox frame to the document and listens for its
102
+ * messages. Call it once the renderer is on the page; a second call does
103
+ * nothing until `stop`.
104
+ */
105
+ start: () => void;
106
+ /**
107
+ * Removes the frame, forgets the programs, withdraws open confirmations
108
+ * and empties the tree. Results that arrive afterwards are dropped. The
109
+ * controller can `start` again.
110
+ */
111
+ stop: () => void;
112
+ /** The tree the program drew, checked against its schemas. Read it from `ROOT_ID`. */
113
+ store: TreeStore;
114
+ /** Fires after the tree or the status changed. Returns the unsubscribe. */
115
+ subscribe: (listener: () => void) => () => void;
116
+ }
117
+ /**
118
+ * The components `program` draws that `registered` lacks, as the issues the
119
+ * view reports for them. Give the result to `setUnregistered`.
120
+ */
121
+ export declare function missingComponents(program: Program, registered: ReadonlyArray<string>): Array<string>;
122
+ /**
123
+ * Page side of the sandbox. Owns the frame, sends programs and handler calls
124
+ * to the worker, applies the ops it returns to `store`, keeps exactly the
125
+ * queries the program reads subscribed through the client, and runs the
126
+ * mutations its handlers call. Messages from anything but its own frame are
127
+ * ignored, and the names a result asks for must be the right kind of
128
+ * procedure in the program, since the worker shares its realm with the
129
+ * program and is not trusted either.
130
+ */
131
+ export declare function createViewController(client: PickleClient): ViewController;
132
+ //#endregion
@@ -0,0 +1,539 @@
1
+ import { frameProblem, unprotectedProblem } from "./frame-check.js";
2
+ import { createTreeStore, parseErrorData, parseProgram } from "@jigs.design/runtime";
3
+ //#region src/view/controller.ts
4
+ const WATCHDOG_NOTICE = "Program stopped: it did not answer within a second. The sandbox was restarted.";
5
+ 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.";
6
+ const idle = {
7
+ errors: [],
8
+ issues: [],
9
+ notice: null
10
+ };
11
+ const FORMAT_NOTICE = "This program was compiled for another version of the runtime; compile it again.";
12
+ /**
13
+ * How long after the frame loads it may take to say it is ready. Its policy
14
+ * check takes a moment, or up to a second when the policy is gone, and then
15
+ * the frame says `unprotected` itself; silence past this means its script
16
+ * never ran.
17
+ */
18
+ const STARTUP_MS = 3e3;
19
+ const MAX_ERROR_TEXT = 1e3;
20
+ /** The prop a registry component reads to focus itself when it mounts. */
21
+ const AUTO_FOCUS = "autoFocus";
22
+ const toWireArgs = (args) => args.filter((arg) => typeof arg === "string" || typeof arg === "number" || typeof arg === "boolean");
23
+ /**
24
+ * The components `program` draws that `registered` lacks, as the issues the
25
+ * view reports for them. Give the result to `setUnregistered`.
26
+ */
27
+ function missingComponents(program, registered) {
28
+ return Object.keys(program.components).filter((name) => !registered.includes(name)).map((name) => `<${name}> is not in the registry, so it renders nothing`);
29
+ }
30
+ const NO_ACTIONS = {
31
+ error: {
32
+ code: "UNAVAILABLE",
33
+ message: "This page handles no actions"
34
+ },
35
+ status: "error"
36
+ };
37
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
38
+ /** The runtime errors of a result, as text React can draw. The frame is not trusted. */
39
+ function parseErrors(list) {
40
+ const errors = [];
41
+ for (const entry of list) {
42
+ if (errors.length >= 20) break;
43
+ if (isRecord(entry) && typeof entry.message === "string") {
44
+ const line = entry.line;
45
+ errors.push({
46
+ line: typeof line === "number" && Number.isSafeInteger(line) ? line : null,
47
+ message: entry.message.slice(0, MAX_ERROR_TEXT)
48
+ });
49
+ }
50
+ }
51
+ return errors;
52
+ }
53
+ /** A message from the frame with the fields its kind needs. The frame is not trusted. */
54
+ function isFrameMessage(value) {
55
+ if (typeof value !== "object" || value === null || !("type" in value)) return false;
56
+ if (value.type === "ready" || value.type === "unprotected") return true;
57
+ if (!("seq" in value) || typeof value.seq !== "number") return false;
58
+ if (value.type === "result") return "ops" in value && Array.isArray(value.ops) && "errors" in value && Array.isArray(value.errors) && "queries" in value && Array.isArray(value.queries) && "mutations" in value && Array.isArray(value.mutations);
59
+ if (value.type === "failed") return "message" in value && typeof value.message === "string";
60
+ return value.type === "quarantined";
61
+ }
62
+ /**
63
+ * Page side of the sandbox. Owns the frame, sends programs and handler calls
64
+ * to the worker, applies the ops it returns to `store`, keeps exactly the
65
+ * queries the program reads subscribed through the client, and runs the
66
+ * mutations its handlers call. Messages from anything but its own frame are
67
+ * ignored, and the names a result asks for must be the right kind of
68
+ * procedure in the program, since the worker shares its realm with the
69
+ * program and is not trusted either.
70
+ */
71
+ function createViewController(client) {
72
+ const store = createTreeStore();
73
+ const listeners = /* @__PURE__ */ new Set();
74
+ const observers = /* @__PURE__ */ new Map();
75
+ const sent = /* @__PURE__ */ new Map();
76
+ let iframe = null;
77
+ let ready = false;
78
+ let queue = [];
79
+ let seq = 0;
80
+ /** The program the worker accepted and is running. */
81
+ let running = null;
82
+ /** The program the view was last given; the worker may not have accepted it yet. */
83
+ let latest = null;
84
+ let latestKey = null;
85
+ /** The document of the last program sent to the worker, as `load` was told. */
86
+ let latestDocument;
87
+ let lastGood = null;
88
+ const updates = /* @__PURE__ */ new Map();
89
+ /** Times each program hung, against `MAX_HANGS`. */
90
+ let hangs = /* @__PURE__ */ new WeakMap();
91
+ /** Handler calls the worker has not answered yet: seq to node. */
92
+ const calls = /* @__PURE__ */ new Map();
93
+ /** Unanswered calls per node, and how often each node's calls all settled. */
94
+ const openCalls = /* @__PURE__ */ new Map();
95
+ const settled = /* @__PURE__ */ new Map();
96
+ /** What the view itself dropped from results since the last program change. */
97
+ let dropped = [];
98
+ let status = idle;
99
+ /** Bumped on stop and on quarantine, so late results are dropped. */
100
+ let generation = 0;
101
+ let startup = null;
102
+ let actionHandler;
103
+ let confirmHandler;
104
+ /**
105
+ * Aborts the confirmations the program on screen asked for, so a question
106
+ * still showing cannot run a mutation for a program that is gone.
107
+ */
108
+ let confirmationScope = new AbortController();
109
+ const withdrawConfirmations = () => {
110
+ confirmationScope.abort();
111
+ confirmationScope = new AbortController();
112
+ };
113
+ let issueHandler;
114
+ /** Issues already reported that are still present, by key. */
115
+ let announced = /* @__PURE__ */ new Set();
116
+ let unregistered = [];
117
+ let reported = false;
118
+ let warnedClone = false;
119
+ /**
120
+ * Reports each issue that was not present before. One that persists is
121
+ * reported once; one that goes away and comes back, again.
122
+ */
123
+ const announce = () => {
124
+ const present = /* @__PURE__ */ new Set();
125
+ const fresh = [];
126
+ const note = (key, issue) => {
127
+ present.add(key);
128
+ if (!announced.has(key)) fresh.push(issue);
129
+ };
130
+ for (const error of status.errors) note(`error:${error.line ?? ""}:${error.message}`, {
131
+ kind: "error",
132
+ line: error.line,
133
+ message: error.message
134
+ });
135
+ for (const message of [...unregistered, ...status.issues]) note(`dropped:${message}`, {
136
+ kind: "dropped",
137
+ message
138
+ });
139
+ if (status.notice !== null) note(`notice:${status.notice}`, {
140
+ kind: "notice",
141
+ message: status.notice
142
+ });
143
+ announced = present;
144
+ const handler = issueHandler;
145
+ for (const issue of fresh) handler?.(issue);
146
+ };
147
+ const setStatus = (next) => {
148
+ status = next;
149
+ for (const listener of listeners) listener();
150
+ announce();
151
+ };
152
+ const drop = (issue) => {
153
+ if (dropped.length < 20 && !dropped.includes(issue)) dropped = [...dropped, issue];
154
+ };
155
+ const clearStartup = () => {
156
+ if (startup !== null) {
157
+ clearTimeout(startup);
158
+ startup = null;
159
+ }
160
+ };
161
+ /** A frame that cannot run is a setup mistake: say so once, in the view and the console. */
162
+ const reportFrame = (problem) => {
163
+ clearStartup();
164
+ if (reported) return;
165
+ reported = true;
166
+ console.error(`Pickle: ${problem}`);
167
+ setStatus({
168
+ ...status,
169
+ notice: problem
170
+ });
171
+ };
172
+ /** Fetches the frame again to tell a replaced policy from other reasons it stayed silent. */
173
+ const diagnoseFrame = () => {
174
+ startup = null;
175
+ const started = generation;
176
+ fetch(client.frame, { cache: "no-store" }).then((response) => frameProblem(client.frame, response, window.location.origin), () => frameProblem(client.frame, null, window.location.origin)).then((problem) => {
177
+ if (started === generation && !ready) reportFrame(problem);
178
+ });
179
+ };
180
+ /** `value` as the worker may receive it, or null when it cannot be copied (a function, say). */
181
+ const cloneable = (value) => {
182
+ try {
183
+ return structuredClone(value ?? null);
184
+ } catch {
185
+ if (!warnedClone) {
186
+ warnedClone = true;
187
+ console.warn("Pickle: an onAction handler returned a value structuredClone cannot copy (a function, a DOM node, a class with private fields), so the program's handle got null as its data.");
188
+ }
189
+ return null;
190
+ }
191
+ };
192
+ /** Gives a checked action to the page's handler and settles the handle with its outcome. */
193
+ const runAction = async (action) => {
194
+ const handler = actionHandler;
195
+ if (handler === void 0) return NO_ACTIONS;
196
+ try {
197
+ return {
198
+ data: cloneable(await handler(action)),
199
+ status: "success"
200
+ };
201
+ } catch (error) {
202
+ return {
203
+ error: parseErrorData(error) ?? {
204
+ code: "INTERNAL",
205
+ message: error instanceof Error ? error.message : String(error)
206
+ },
207
+ status: "error"
208
+ };
209
+ }
210
+ };
211
+ const post = (message) => {
212
+ if (ready && iframe?.contentWindow) iframe.contentWindow.postMessage(message, "*");
213
+ else queue.push(message);
214
+ };
215
+ const sendUpdate = (next, fresh) => {
216
+ seq++;
217
+ updates.set(seq, next);
218
+ post({
219
+ code: next.code,
220
+ fresh,
221
+ procedures: next.procedures,
222
+ prose: next.prose,
223
+ seq,
224
+ type: "update"
225
+ });
226
+ };
227
+ const sendQuery = (key, result) => {
228
+ const text = JSON.stringify(result);
229
+ if (sent.get(key) === text) return;
230
+ sent.set(key, text);
231
+ if (result.status === "pending") return;
232
+ seq++;
233
+ post({
234
+ key,
235
+ result,
236
+ seq,
237
+ type: "query"
238
+ });
239
+ };
240
+ const dropObservers = () => {
241
+ for (const unsubscribe of observers.values()) unsubscribe();
242
+ observers.clear();
243
+ sent.clear();
244
+ };
245
+ /** The running program's entry for `name`, if it is one of `kinds`. */
246
+ const procedure = (name, kinds) => {
247
+ if (running === null || !Object.hasOwn(running.procedures, name)) return;
248
+ const entry = running.procedures[name];
249
+ return entry !== void 0 && kinds.includes(entry.kind) ? entry : void 0;
250
+ };
251
+ /** The queries a result reads that the program declares, up to `MAX_QUERIES`. */
252
+ const checkedReads = (reads) => {
253
+ const wanted = /* @__PURE__ */ new Map();
254
+ for (const read of reads) {
255
+ if (!isRecord(read) || typeof read.key !== "string" || typeof read.name !== "string" || procedure(read.name, ["query"]) === void 0) {
256
+ drop("The sandbox read a query this program does not declare");
257
+ continue;
258
+ }
259
+ if (!wanted.has(read.key) && wanted.size >= 20) {
260
+ drop(`The sandbox read more than 20 queries`);
261
+ continue;
262
+ }
263
+ wanted.set(read.key, {
264
+ input: read.input,
265
+ key: read.key,
266
+ name: read.name
267
+ });
268
+ }
269
+ return wanted;
270
+ };
271
+ const syncQueries = (wanted) => {
272
+ for (const [key, unsubscribe] of observers) if (!wanted.has(key)) {
273
+ unsubscribe();
274
+ observers.delete(key);
275
+ sent.delete(key);
276
+ }
277
+ for (const [key, read] of wanted) if (!observers.has(key)) observers.set(key, client.observe(read.name, read.input, (result) => {
278
+ sendQuery(key, result);
279
+ }));
280
+ };
281
+ /** Marks the call `answered` done; returns whether it was a handler call. */
282
+ const answer = (answered) => {
283
+ const node = calls.get(answered);
284
+ if (node === void 0) return false;
285
+ calls.delete(answered);
286
+ const left = (openCalls.get(node) ?? 1) - 1;
287
+ if (left > 0) openCalls.set(node, left);
288
+ else {
289
+ openCalls.delete(node);
290
+ settled.set(node, (settled.get(node) ?? 0) + 1);
291
+ }
292
+ return true;
293
+ };
294
+ const forgetCalls = () => {
295
+ calls.clear();
296
+ openCalls.clear();
297
+ settled.clear();
298
+ };
299
+ const runMutations = (mutations, fromHandler) => {
300
+ if (mutations.length > 0 && !fromHandler) {
301
+ drop("The sandbox asked for a mutation outside an event handler");
302
+ return;
303
+ }
304
+ if (mutations.length > 5) drop(`The sandbox asked for more than 5 mutations at once`);
305
+ const started = generation;
306
+ for (const mutation of mutations.slice(0, 5)) {
307
+ const call = isRecord(mutation) && typeof mutation.id === "number" && typeof mutation.name === "string" ? {
308
+ id: mutation.id,
309
+ input: mutation.input,
310
+ name: mutation.name
311
+ } : null;
312
+ const entry = call === null ? void 0 : procedure(call.name, ["action", "mutation"]);
313
+ if (call === null || entry === void 0) {
314
+ drop("The sandbox called a mutation this program does not declare");
315
+ continue;
316
+ }
317
+ const kind = entry.kind === "action" ? "action" : "mutation";
318
+ client.mutate(call.name, call.input, {
319
+ confirm: confirmHandler,
320
+ invalidates: entry.invalidates,
321
+ kind,
322
+ signal: confirmationScope.signal
323
+ }).then((result) => kind === "action" && result.status === "success" && started === generation ? runAction({
324
+ input: result.data,
325
+ name: call.name
326
+ }) : result).then((result) => {
327
+ if (started === generation) {
328
+ seq++;
329
+ post({
330
+ id: call.id,
331
+ result,
332
+ seq,
333
+ type: "mutation"
334
+ });
335
+ }
336
+ });
337
+ }
338
+ };
339
+ const onMessage = (event) => {
340
+ if (iframe === null || event.source !== iframe.contentWindow) return;
341
+ const message = event.data;
342
+ if (!isFrameMessage(message)) return;
343
+ switch (message.type) {
344
+ case "unprotected":
345
+ reportFrame(unprotectedProblem(client.frame));
346
+ return;
347
+ case "ready": {
348
+ clearStartup();
349
+ ready = true;
350
+ const queued = queue;
351
+ queue = [];
352
+ for (const item of queued) post(item);
353
+ return;
354
+ }
355
+ case "result": {
356
+ const update = updates.get(message.seq);
357
+ updates.delete(message.seq);
358
+ if (update !== void 0) {
359
+ running = update;
360
+ lastGood = update;
361
+ dropped = [];
362
+ store.setSchemas(update);
363
+ }
364
+ store.apply(message.ops);
365
+ const fromHandler = answer(message.seq);
366
+ const wanted = checkedReads(message.queries);
367
+ runMutations(message.mutations, fromHandler);
368
+ const newest = update !== void 0 && update === latest && !hangs.has(update);
369
+ setStatus({
370
+ errors: parseErrors(message.errors),
371
+ issues: [...store.getIssues(), ...dropped],
372
+ notice: newest ? null : status.notice
373
+ });
374
+ syncQueries(wanted);
375
+ return;
376
+ }
377
+ case "failed": {
378
+ const failed = updates.get(message.seq);
379
+ updates.delete(message.seq);
380
+ answer(message.seq);
381
+ if (failed !== void 0 && failed === latest) latest = running;
382
+ setStatus({
383
+ ...status,
384
+ notice: `Program failed (${message.message}); showing the last working version.`
385
+ });
386
+ return;
387
+ }
388
+ case "quarantined": {
389
+ const failed = updates.get(message.seq);
390
+ const culprit = failed ?? running ?? latest;
391
+ updates.clear();
392
+ generation++;
393
+ withdrawConfirmations();
394
+ dropObservers();
395
+ forgetCalls();
396
+ store.reset();
397
+ running = null;
398
+ if (culprit !== null) {
399
+ const before = hangs.get(culprit) ?? 0;
400
+ hangs.set(culprit, failed === void 0 ? before + 1 : 2);
401
+ if (before === 0) console.warn(`Pickle: ${WATCHDOG_NOTICE}`);
402
+ }
403
+ const usable = (candidate) => candidate !== null && (hangs.get(candidate) ?? 0) < 2;
404
+ let recovery = null;
405
+ if (usable(latest)) recovery = latest;
406
+ else if (usable(lastGood)) recovery = lastGood;
407
+ setStatus({
408
+ errors: [],
409
+ issues: [],
410
+ notice: recovery === null ? GAVE_UP_NOTICE : WATCHDOG_NOTICE
411
+ });
412
+ if (recovery !== null) sendUpdate(recovery, false);
413
+ return;
414
+ }
415
+ }
416
+ };
417
+ const call = (node, fn, args) => {
418
+ seq++;
419
+ calls.set(seq, node);
420
+ openCalls.set(node, (openCalls.get(node) ?? 0) + 1);
421
+ post({
422
+ args,
423
+ fn,
424
+ seq,
425
+ type: "call"
426
+ });
427
+ };
428
+ const propKind = (type, name) => {
429
+ const component = running !== null && Object.hasOwn(running.components, type) ? running.components[type] : void 0;
430
+ return component !== void 0 && Object.hasOwn(component.props, name) ? component.props[name]?.kind : void 0;
431
+ };
432
+ return {
433
+ call,
434
+ childrenAllowed: (type) => running !== null && Object.hasOwn(running.components, type) && running.components[type]?.children !== "none",
435
+ drawnProps: (node, options) => {
436
+ const drawn = {};
437
+ for (const [name, value] of Object.entries(node.props)) {
438
+ if (name === AUTO_FOCUS && !options.allowAutoFocus) continue;
439
+ const kind = propKind(node.type, name);
440
+ if (kind === "handler") {
441
+ if (typeof value === "object" && "fn" in value) {
442
+ const fn = value.fn;
443
+ drawn[name] = (...args) => {
444
+ call(node.id, fn, toWireArgs(args));
445
+ };
446
+ }
447
+ } else if (kind === "list") {
448
+ if (typeof value === "object" && "rows" in value) drawn[name] = value.rows;
449
+ } else if (kind !== void 0 && typeof value !== "object") drawn[name] = value;
450
+ }
451
+ return drawn;
452
+ },
453
+ getSettled: (node) => settled.get(node) ?? 0,
454
+ getStatus: () => status,
455
+ load: (next, document) => {
456
+ const fresh = document !== latestDocument;
457
+ const key = JSON.stringify(next);
458
+ if (key === latestKey && !fresh) return;
459
+ latestKey = key;
460
+ const checked = parseProgram(next);
461
+ if (checked === null) {
462
+ setStatus({
463
+ ...status,
464
+ notice: FORMAT_NOTICE
465
+ });
466
+ return;
467
+ }
468
+ latest = checked;
469
+ latestDocument = document;
470
+ if (fresh) withdrawConfirmations();
471
+ sendUpdate(checked, fresh);
472
+ },
473
+ propKind,
474
+ setActionHandler: (handler) => {
475
+ actionHandler = handler;
476
+ },
477
+ setConfirmHandler: (handler) => {
478
+ confirmHandler = handler;
479
+ },
480
+ setIssueHandler: (handler) => {
481
+ issueHandler = handler;
482
+ },
483
+ setUnregistered: (issues) => {
484
+ unregistered = issues;
485
+ announce();
486
+ },
487
+ start: () => {
488
+ if (iframe !== null) return;
489
+ const frame = document.createElement("iframe");
490
+ frame.setAttribute("sandbox", "allow-scripts");
491
+ frame.setAttribute("aria-hidden", "true");
492
+ frame.setAttribute("tabindex", "-1");
493
+ frame.style.display = "none";
494
+ frame.src = client.frame;
495
+ frame.addEventListener("load", () => {
496
+ if (iframe === frame && !ready && startup === null) startup = setTimeout(diagnoseFrame, STARTUP_MS);
497
+ });
498
+ iframe = frame;
499
+ window.addEventListener("message", onMessage);
500
+ document.body.append(frame);
501
+ },
502
+ stop: () => {
503
+ generation++;
504
+ window.removeEventListener("message", onMessage);
505
+ iframe?.remove();
506
+ iframe = null;
507
+ ready = false;
508
+ clearStartup();
509
+ reported = false;
510
+ queue = [];
511
+ updates.clear();
512
+ hangs = /* @__PURE__ */ new WeakMap();
513
+ forgetCalls();
514
+ dropped = [];
515
+ lastGood = null;
516
+ running = null;
517
+ latest = null;
518
+ latestKey = null;
519
+ latestDocument = void 0;
520
+ actionHandler = void 0;
521
+ confirmHandler = void 0;
522
+ withdrawConfirmations();
523
+ dropObservers();
524
+ store.reset();
525
+ status = idle;
526
+ },
527
+ store,
528
+ subscribe: (listener) => {
529
+ listeners.add(listener);
530
+ const unsubscribe = store.subscribe(listener);
531
+ return () => {
532
+ listeners.delete(listener);
533
+ unsubscribe();
534
+ };
535
+ }
536
+ };
537
+ }
538
+ //#endregion
539
+ export { MAX_ERROR_TEXT, STARTUP_MS, createViewController, missingComponents };
@@ -0,0 +1,32 @@
1
+ import { frameSecurityPolicy } from "@jigs.design/runtime";
2
+ //#region src/view/frame-check.ts
3
+ const ADVICE = "Exclude that route from your page policy: `contentSecurityPolicy(host, { endpoint }).appliesTo(request.url)` is false for it.";
4
+ /**
5
+ * The issue for a frame that started and found its own policy missing: its
6
+ * scripts ran, so whatever replaced the policy does not block them, but the
7
+ * frame's network lock is gone and it refuses to run programs.
8
+ */
9
+ function unprotectedProblem(frame) {
10
+ return `The sandbox frame's Content-Security-Policy is not in force on ${frame}, so the sandbox will not run. A page policy replaced it, or something stripped it. ${ADVICE}`;
11
+ }
12
+ /**
13
+ * The issue for a frame that never said it was ready. `response` is the
14
+ * frame as the view fetched it again, or `null` when that fetch failed, as
15
+ * it does for an endpoint on another origin that does not list the page's
16
+ * origin, `pageOrigin`, in `allowedOrigins`. A response can carry several
17
+ * policies, which `Headers` joins with commas; the frame's own starts with
18
+ * `frameSecurityPolicy`.
19
+ */
20
+ function frameProblem(frame, response, pageOrigin) {
21
+ if (response === null) {
22
+ if ((URL.canParse(frame, pageOrigin) ? new URL(frame, pageOrigin).origin : pageOrigin) !== pageOrigin) return `The sandbox frame at ${frame} did not start, and its response could not be read to say why. It is on another origin: check that the server's \`allowedOrigins\` lists ${pageOrigin}, which its frame-ancestors and CORS headers both need, and that nothing replaces the Content-Security-Policy it sends.`;
23
+ return `The sandbox frame at ${frame} did not start, and its response could not be read to say why. Check that nothing replaces the Content-Security-Policy it sends.`;
24
+ }
25
+ if (response.status !== 200) return `The sandbox frame at ${frame} answered ${response.status}, so the view cannot start. Check that the route hands GET requests to \`pickle.handler\`.`;
26
+ const policies = (response.headers.get("content-security-policy") ?? "").split(",").map((policy) => policy.trim()).filter((policy) => policy !== "");
27
+ if (!policies.some((policy) => policy.startsWith(frameSecurityPolicy))) return `The page's Content-Security-Policy replaced the sandbox frame's on ${frame}, so the sandbox will not run. ${ADVICE}`;
28
+ if (policies.length > 1) return `A second Content-Security-Policy is added to the sandbox frame's response on ${frame}, and it stops the frame from starting. ${ADVICE}`;
29
+ return `The sandbox frame at ${frame} did not start. Its policy is intact; check the browser console for the frame's own errors.`;
30
+ }
31
+ //#endregion
32
+ export { frameProblem, unprotectedProblem };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jigs.design/client",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "createPickleClient: the browser's transport and query cache. No React.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,6 +26,6 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@tanstack/query-core": "5.104.4",
29
- "@jigs.design/runtime": "0.1.0"
29
+ "@jigs.design/runtime": "0.1.1"
30
30
  }
31
31
  }