@jigs.design/react 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
@@ -29,24 +29,25 @@ The program runs in a worker inside a frame served by your Pickle route. The fra
29
29
 
30
30
  ## Exports
31
31
 
32
- | Export | What it is |
33
- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
34
- | `PickleView` | Runs one program and draws it. Takes `client`, `components`, `program`, `onAction`, `showIssues`, `allowAutoFocus` |
35
- | `usePickleSession` | The conversation and its latest program. `initialMessages` resumes one |
36
- | `useHandlerSettled` | For a registry control: changes once the program has answered its events, so it can drop an edit the program refused |
37
- | `Registry`, `RegistryFor` | The type of a map from catalog names to React components |
38
- | `SessionState`, `PickleSession` | The session's state and the hook's return value |
39
-
40
- It re-exports the shared types from `@jigs.design/runtime`.
32
+ | Export | What it is |
33
+ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
34
+ | `PickleView` | Runs one program and draws it. Takes `client`, `components`, `program`, `onAction`, `showIssues`, `allowAutoFocus` |
35
+ | `usePickleSession` | The conversation and its latest program. `initialMessages` resumes one |
36
+ | `useHandlerSettled` | For a registry control: changes once the program has answered its events, so it can drop an edit the program refused |
37
+ | `useLocalValue`, `controlled` | For a controlled control: a local copy of the program's value that runs ahead of the round trip and drops a refused edit |
38
+ | `Registry`, `RegistryFor` | The type of a map from catalog names to React components |
39
+ | `SessionState`, `PickleSession` | The session's state and the hook's return value |
40
+ | `ViewIssue` | What `onIssue` receives |
41
+
42
+ It re-exports the shared types from `@jigs.design/runtime`. The frame, its messages and the calls through the client are `createViewController` in `@jigs.design/client`, which `PickleView` is built on; a renderer for another framework starts there.
41
43
 
42
44
  ## Source
43
45
 
44
- - `src/view/PickleView.tsx`: the view component.
45
- - `src/view/controller.ts`: the frame, its messages, and the calls it makes through the client.
46
- - `src/view/frame-check.ts`: tells you when a page policy broke the frame's own.
46
+ - `src/view/PickleView.tsx`: the view component, on the client's view controller.
47
47
  - `src/view/registry.ts`, `src/view/settled.ts`: the registry types and `useHandlerSettled`.
48
+ - `src/view/local-value.ts`: `useLocalValue` and `controlled`, for controls.
48
49
  - `src/view/session.ts`, `src/view/usePickleSession.ts`: the session store and its hook.
49
50
 
50
51
  ## Docs
51
52
 
52
- [React](../../apps/docs/docs/react/index.mdx), [Registry](../../apps/docs/docs/react/registry.mdx), [Sandbox](../../apps/docs/docs/react/sandbox.mdx), [Session](../../apps/docs/docs/running/session.mdx).
53
+ [React](../../apps/docs/docs/react/index.mdx), [Registry](../../apps/docs/docs/react/registry.mdx), [Sandbox](../../apps/docs/docs/react/sandbox.mdx), [Session](../../apps/docs/docs/running/session.mdx), [Renderers](../../apps/docs/docs/renderers/index.mdx).
package/dist/index.d.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { ViewIssue } from "./view/controller.js";
1
+ import { controlled, useLocalValue } from "./view/local-value.js";
2
2
  import { Registry, RegistryFor } from "./view/registry.js";
3
3
  import { PickleView } from "./view/PickleView.js";
4
4
  import { useHandlerSettled } from "./view/settled.js";
5
5
  import { SessionState } from "./view/session.js";
6
6
  import { PickleSession, usePickleSession } from "./view/usePickleSession.js";
7
+ import { ConfirmFunction, ViewIssue } from "@jigs.design/client";
7
8
  import { Diagnostic, Message, PickleErrorCode, PickleErrorData, Program, SessionEvent, SessionOptions, Span } from "@jigs.design/runtime";
8
- import { ConfirmFunction } from "@jigs.design/client";
9
- export { type ConfirmFunction, type Diagnostic, type Message, type PickleErrorCode, type PickleErrorData, type PickleSession, PickleView, type Program, type Registry, type RegistryFor, type SessionEvent, type SessionOptions, type SessionState, type Span, type ViewIssue, useHandlerSettled, usePickleSession };
9
+ export { type ConfirmFunction, type Diagnostic, type Message, type PickleErrorCode, type PickleErrorData, type PickleSession, PickleView, type Program, type Registry, type RegistryFor, type SessionEvent, type SessionOptions, type SessionState, type Span, type ViewIssue, controlled, useHandlerSettled, useLocalValue, usePickleSession };
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { useHandlerSettled } from "./view/settled.js";
2
+ import { controlled, useLocalValue } from "./view/local-value.js";
2
3
  import { PickleView } from "./view/PickleView.js";
3
4
  import { usePickleSession } from "./view/usePickleSession.js";
4
- export { PickleView, useHandlerSettled, usePickleSession };
5
+ export { PickleView, controlled, useHandlerSettled, useLocalValue, usePickleSession };
@@ -1,7 +1,6 @@
1
- import { ViewIssue } from "./controller.js";
2
1
  import { Registry } from "./registry.js";
2
+ import { ConfirmFunction, PickleClient, ViewIssue } from "@jigs.design/client";
3
3
  import { Program } from "@jigs.design/runtime";
4
- import { ConfirmFunction, PickleClient } from "@jigs.design/client";
5
4
  //#region src/view/PickleView.d.ts
6
5
  /** An action as `onAction` receives it; type it with `ActionEvent<typeof host>`. */
7
6
  interface ActionLike {
@@ -1,12 +1,9 @@
1
- import { createViewController } from "./controller.js";
2
1
  import { SettledContext } from "./settled.js";
3
- import { ROOT_ID } from "@jigs.design/runtime";
4
2
  import { useEffect, useState, useSyncExternalStore } from "react";
3
+ import { createViewController, missingComponents } from "@jigs.design/client";
4
+ import { ROOT_ID } from "@jigs.design/runtime";
5
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
6
  //#region src/view/PickleView.tsx
7
- /** The prop a registry component reads to focus itself when it mounts. */
8
- const AUTO_FOCUS = "autoFocus";
9
- const toWireArgs = (args) => args.filter((arg) => typeof arg === "string" || typeof arg === "number" || typeof arg === "boolean");
10
7
  function TreeNodeView(props) {
11
8
  const { components, controller } = props;
12
9
  const getNode = () => controller.store.get(props.id);
@@ -24,21 +21,7 @@ function TreeNodeView(props) {
24
21
  if (node.type === "#root") return children;
25
22
  const registered = Object.hasOwn(components, node.type) ? components[node.type] : void 0;
26
23
  if (registered === void 0) return null;
27
- const drawn = {};
28
- for (const [name, value] of Object.entries(node.props)) {
29
- if (name === AUTO_FOCUS && !props.allowAutoFocus) continue;
30
- const kind = controller.propKind(node.type, name);
31
- if (kind === "handler") {
32
- if (typeof value === "object" && "fn" in value) {
33
- const fn = value.fn;
34
- drawn[name] = (...args) => {
35
- controller.call(node.id, fn, toWireArgs(args));
36
- };
37
- }
38
- } else if (kind === "list") {
39
- if (typeof value === "object" && "rows" in value) drawn[name] = value.rows;
40
- } else if (kind !== void 0 && typeof value !== "object") drawn[name] = value;
41
- }
24
+ const drawn = controller.drawnProps(node, { allowAutoFocus: props.allowAutoFocus });
42
25
  return /* @__PURE__ */ jsx(SettledContext, {
43
26
  value: settled,
44
27
  children: /* @__PURE__ */ jsx(registered, {
@@ -47,9 +30,7 @@ function TreeNodeView(props) {
47
30
  })
48
31
  });
49
32
  }
50
- function missingFromRegistry(program, components) {
51
- return Object.keys(program.components).filter((name) => !Object.hasOwn(components, name)).map((name) => `<${name}> is not in the registry, so it renders nothing`);
52
- }
33
+ const missingFromRegistry = (program, components) => missingComponents(program, Object.keys(components));
53
34
  /**
54
35
  * Runs one program in the sandbox and draws it with `components`. A new
55
36
  * `program` replaces the running one; state the new program still declares
@@ -0,0 +1,19 @@
1
+ //#region src/view/local-value.d.ts
2
+ /**
3
+ * Whether the program controls a control's value: it sets the value prop
4
+ * (`value`, or `checked`) and handles `onChange`. A control with only
5
+ * `onChange` is uncontrolled, like a React input without `value`: the
6
+ * program hears each edit but the control keeps what the user did.
7
+ */
8
+ export declare const controlled: <T>(value: T | undefined, onChange: ((next: T) => void) | undefined) => boolean;
9
+ /**
10
+ * A local copy of a value the program owns, so a control can run ahead of the
11
+ * round trip to the sandbox. Pass `echoes` when the program controls the
12
+ * value (`controlled`): echoes of the edits sent to it then don't reset the
13
+ * control, but any other new value does, and so does an edit the program did
14
+ * not take: once it has answered every call (`useHandlerSettled`) and `value`
15
+ * is still not the edit, the control goes back to `value`. Without `echoes`,
16
+ * the control keeps its own state until `value` changes.
17
+ */
18
+ export declare function useLocalValue<T>(value: T, echoes: boolean): [T, (next: T) => void];
19
+ //#endregion
@@ -0,0 +1,47 @@
1
+ import { useHandlerSettled } from "./settled.js";
2
+ import { useState } from "react";
3
+ //#region src/view/local-value.ts
4
+ /**
5
+ * Whether the program controls a control's value: it sets the value prop
6
+ * (`value`, or `checked`) and handles `onChange`. A control with only
7
+ * `onChange` is uncontrolled, like a React input without `value`: the
8
+ * program hears each edit but the control keeps what the user did.
9
+ */
10
+ const controlled = (value, onChange) => value !== void 0 && onChange !== void 0;
11
+ /**
12
+ * A local copy of a value the program owns, so a control can run ahead of the
13
+ * round trip to the sandbox. Pass `echoes` when the program controls the
14
+ * value (`controlled`): echoes of the edits sent to it then don't reset the
15
+ * control, but any other new value does, and so does an edit the program did
16
+ * not take: once it has answered every call (`useHandlerSettled`) and `value`
17
+ * is still not the edit, the control goes back to `value`. Without `echoes`,
18
+ * the control keeps its own state until `value` changes.
19
+ */
20
+ function useLocalValue(value, echoes) {
21
+ const settled = useHandlerSettled();
22
+ const [local, setLocal] = useState(value);
23
+ const [seen, setSeen] = useState(value);
24
+ const [seenSettled, setSeenSettled] = useState(settled);
25
+ const [pending, setPending] = useState([]);
26
+ if (!Object.is(value, seen)) {
27
+ setSeen(value);
28
+ const echo = pending.findIndex((sent) => Object.is(sent, value));
29
+ if (echo === -1) {
30
+ setLocal(value);
31
+ setPending([]);
32
+ } else setPending(pending.slice(echo + 1));
33
+ } else if (settled !== seenSettled) {
34
+ setSeenSettled(settled);
35
+ if (pending.length > 0) {
36
+ setLocal(value);
37
+ setPending([]);
38
+ }
39
+ }
40
+ const edit = (next) => {
41
+ setLocal(next);
42
+ if (echoes) setPending([...pending.slice(-20), next]);
43
+ };
44
+ return [local, edit];
45
+ }
46
+ //#endregion
47
+ export { controlled, useLocalValue };
@@ -1,5 +1,5 @@
1
- import { Diagnostic, Message, PickleErrorData, Program, SessionOptions } from "@jigs.design/runtime";
2
1
  import { PickleClient } from "@jigs.design/client";
2
+ import { Diagnostic, Message, PickleErrorData, Program, SessionOptions } from "@jigs.design/runtime";
3
3
  //#region src/view/session.d.ts
4
4
  export interface SessionState {
5
5
  diagnostics: ReadonlyArray<Diagnostic>;
@@ -1,6 +1,6 @@
1
1
  import { SessionState } from "./session.js";
2
- import { Message, SessionOptions } from "@jigs.design/runtime";
3
2
  import { PickleClient } from "@jigs.design/client";
3
+ import { Message, SessionOptions } from "@jigs.design/runtime";
4
4
  //#region src/view/usePickleSession.d.ts
5
5
  export interface PickleSession<TOptions extends SessionOptions = SessionOptions> extends SessionState {
6
6
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jigs.design/react",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "The web renderer: PickleView runs a compiled program in a sandbox and draws it with your registry; usePickleSession streams programs from the server.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,8 +25,8 @@
25
25
  "access": "public"
26
26
  },
27
27
  "dependencies": {
28
- "@jigs.design/runtime": "0.1.0",
29
- "@jigs.design/client": "0.1.0"
28
+ "@jigs.design/runtime": "0.1.1",
29
+ "@jigs.design/client": "0.1.1"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "react": "^18.0.0 || ^19.0.0"
@@ -1,22 +0,0 @@
1
- import { Program } from "@jigs.design/runtime";
2
- import { ConfirmFunction, PickleClient } from "@jigs.design/client";
3
- //#region src/view/controller.d.ts
4
- /**
5
- * A problem the view found, as `onIssue` receives it: a runtime error the
6
- * program threw, something the view dropped (a prop or component that failed
7
- * its schema, a request the program does not declare, a component missing
8
- * from the registry), or a notice that the sandbox rejected or stopped a
9
- * program or cannot run.
10
- */
11
- export type ViewIssue = {
12
- kind: "dropped";
13
- message: string;
14
- } | {
15
- kind: "error";
16
- line: number | null;
17
- message: string;
18
- } | {
19
- kind: "notice";
20
- message: string;
21
- };
22
- //#endregion
@@ -1,524 +0,0 @@
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
- /**
20
- * The runtime's own limits, checked again here because the worker shares a
21
- * realm with the program: different requests a render may read, and
22
- * mutations one handler call may make.
23
- */
24
- const MAX_QUERIES = 20;
25
- const MAX_MUTATIONS = 5;
26
- /** Runtime errors the view keeps from one result, and the length of each. */
27
- const MAX_ERRORS = 20;
28
- const MAX_ERROR_TEXT = 1e3;
29
- /**
30
- * Times a program may hang before the view stops restarting it. One that
31
- * hangs on its first render is not tried again; one that hangs later, on
32
- * data or a handler, gets one restart with fresh state.
33
- */
34
- const MAX_HANGS = 2;
35
- const NO_ACTIONS = {
36
- error: {
37
- code: "UNAVAILABLE",
38
- message: "This page handles no actions"
39
- },
40
- status: "error"
41
- };
42
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
43
- /** The runtime errors of a result, as text React can draw. The frame is not trusted. */
44
- function parseErrors(list) {
45
- const errors = [];
46
- for (const entry of list) {
47
- if (errors.length >= MAX_ERRORS) break;
48
- if (isRecord(entry) && typeof entry.message === "string") {
49
- const line = entry.line;
50
- errors.push({
51
- line: typeof line === "number" && Number.isSafeInteger(line) ? line : null,
52
- message: entry.message.slice(0, MAX_ERROR_TEXT)
53
- });
54
- }
55
- }
56
- return errors;
57
- }
58
- /** A message from the frame with the fields its kind needs. The frame is not trusted. */
59
- function isFrameMessage(value) {
60
- if (typeof value !== "object" || value === null || !("type" in value)) return false;
61
- if (value.type === "ready" || value.type === "unprotected") return true;
62
- if (!("seq" in value) || typeof value.seq !== "number") return false;
63
- 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);
64
- if (value.type === "failed") return "message" in value && typeof value.message === "string";
65
- return value.type === "quarantined";
66
- }
67
- /**
68
- * Page side of the sandbox. Owns the frame, sends programs and handler calls
69
- * to the worker, applies the ops it returns to `store`, keeps exactly the
70
- * queries the program reads subscribed through the client, and runs the
71
- * mutations its handlers call. Messages from anything but its own frame are
72
- * ignored, and the names a result asks for must be the right kind of
73
- * procedure in the program, since the worker shares its realm with the
74
- * program and is not trusted either.
75
- */
76
- function createViewController(client) {
77
- const store = createTreeStore();
78
- const listeners = /* @__PURE__ */ new Set();
79
- const observers = /* @__PURE__ */ new Map();
80
- const sent = /* @__PURE__ */ new Map();
81
- let iframe = null;
82
- let ready = false;
83
- let queue = [];
84
- let seq = 0;
85
- /** The program the worker accepted and is running. */
86
- let running = null;
87
- /** The program the view was last given; the worker may not have accepted it yet. */
88
- let latest = null;
89
- let latestKey = null;
90
- /** The document of the last program sent to the worker, as `load` was told. */
91
- let latestDocument;
92
- let lastGood = null;
93
- const updates = /* @__PURE__ */ new Map();
94
- /** Times each program hung, against `MAX_HANGS`. */
95
- let hangs = /* @__PURE__ */ new WeakMap();
96
- /** Handler calls the worker has not answered yet: seq to node. */
97
- const calls = /* @__PURE__ */ new Map();
98
- /** Unanswered calls per node, and how often each node's calls all settled. */
99
- const openCalls = /* @__PURE__ */ new Map();
100
- const settled = /* @__PURE__ */ new Map();
101
- /** What the view itself dropped from results since the last program change. */
102
- let dropped = [];
103
- let status = idle;
104
- /** Bumped on stop and on quarantine, so late results are dropped. */
105
- let generation = 0;
106
- let startup = null;
107
- let actionHandler;
108
- let confirmHandler;
109
- /**
110
- * Aborts the confirmations the program on screen asked for, so a question
111
- * still showing cannot run a mutation for a program that is gone.
112
- */
113
- let confirmationScope = new AbortController();
114
- const withdrawConfirmations = () => {
115
- confirmationScope.abort();
116
- confirmationScope = new AbortController();
117
- };
118
- let issueHandler;
119
- /** Issues already reported that are still present, by key. */
120
- let announced = /* @__PURE__ */ new Set();
121
- let unregistered = [];
122
- let reported = false;
123
- let warnedClone = false;
124
- /**
125
- * Reports each issue that was not present before. One that persists is
126
- * reported once; one that goes away and comes back, again.
127
- */
128
- const announce = () => {
129
- const present = /* @__PURE__ */ new Set();
130
- const fresh = [];
131
- const note = (key, issue) => {
132
- present.add(key);
133
- if (!announced.has(key)) fresh.push(issue);
134
- };
135
- for (const error of status.errors) note(`error:${error.line ?? ""}:${error.message}`, {
136
- kind: "error",
137
- line: error.line,
138
- message: error.message
139
- });
140
- for (const message of [...unregistered, ...status.issues]) note(`dropped:${message}`, {
141
- kind: "dropped",
142
- message
143
- });
144
- if (status.notice !== null) note(`notice:${status.notice}`, {
145
- kind: "notice",
146
- message: status.notice
147
- });
148
- announced = present;
149
- const handler = issueHandler;
150
- for (const issue of fresh) handler?.(issue);
151
- };
152
- const setStatus = (next) => {
153
- status = next;
154
- for (const listener of listeners) listener();
155
- announce();
156
- };
157
- const drop = (issue) => {
158
- if (dropped.length < MAX_ERRORS && !dropped.includes(issue)) dropped = [...dropped, issue];
159
- };
160
- const clearStartup = () => {
161
- if (startup !== null) {
162
- clearTimeout(startup);
163
- startup = null;
164
- }
165
- };
166
- /** A frame that cannot run is a setup mistake: say so once, in the view and the console. */
167
- const reportFrame = (problem) => {
168
- clearStartup();
169
- if (reported) return;
170
- reported = true;
171
- console.error(`Pickle: ${problem}`);
172
- setStatus({
173
- ...status,
174
- notice: problem
175
- });
176
- };
177
- /** Fetches the frame again to tell a replaced policy from other reasons it stayed silent. */
178
- const diagnoseFrame = () => {
179
- startup = null;
180
- const started = generation;
181
- fetch(client.frame, { cache: "no-store" }).then((response) => frameProblem(client.frame, response, window.location.origin), () => frameProblem(client.frame, null, window.location.origin)).then((problem) => {
182
- if (started === generation && !ready) reportFrame(problem);
183
- });
184
- };
185
- /** `value` as the worker may receive it, or null when it cannot be copied (a function, say). */
186
- const cloneable = (value) => {
187
- try {
188
- return structuredClone(value ?? null);
189
- } catch {
190
- if (!warnedClone) {
191
- warnedClone = true;
192
- 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.");
193
- }
194
- return null;
195
- }
196
- };
197
- /** Gives a checked action to the page's handler and settles the handle with its outcome. */
198
- const runAction = async (action) => {
199
- const handler = actionHandler;
200
- if (handler === void 0) return NO_ACTIONS;
201
- try {
202
- return {
203
- data: cloneable(await handler(action)),
204
- status: "success"
205
- };
206
- } catch (error) {
207
- return {
208
- error: parseErrorData(error) ?? {
209
- code: "INTERNAL",
210
- message: error instanceof Error ? error.message : String(error)
211
- },
212
- status: "error"
213
- };
214
- }
215
- };
216
- const post = (message) => {
217
- if (ready && iframe?.contentWindow) iframe.contentWindow.postMessage(message, "*");
218
- else queue.push(message);
219
- };
220
- const sendUpdate = (next, fresh) => {
221
- seq++;
222
- updates.set(seq, next);
223
- post({
224
- code: next.code,
225
- fresh,
226
- procedures: next.procedures,
227
- prose: next.prose,
228
- seq,
229
- type: "update"
230
- });
231
- };
232
- const sendQuery = (key, result) => {
233
- const text = JSON.stringify(result);
234
- if (sent.get(key) === text) return;
235
- sent.set(key, text);
236
- if (result.status === "pending") return;
237
- seq++;
238
- post({
239
- key,
240
- result,
241
- seq,
242
- type: "query"
243
- });
244
- };
245
- const dropObservers = () => {
246
- for (const unsubscribe of observers.values()) unsubscribe();
247
- observers.clear();
248
- sent.clear();
249
- };
250
- /** The running program's entry for `name`, if it is one of `kinds`. */
251
- const procedure = (name, kinds) => {
252
- if (running === null || !Object.hasOwn(running.procedures, name)) return;
253
- const entry = running.procedures[name];
254
- return entry !== void 0 && kinds.includes(entry.kind) ? entry : void 0;
255
- };
256
- /** The queries a result reads that the program declares, up to `MAX_QUERIES`. */
257
- const checkedReads = (reads) => {
258
- const wanted = /* @__PURE__ */ new Map();
259
- for (const read of reads) {
260
- if (!isRecord(read) || typeof read.key !== "string" || typeof read.name !== "string" || procedure(read.name, ["query"]) === void 0) {
261
- drop("The sandbox read a query this program does not declare");
262
- continue;
263
- }
264
- if (!wanted.has(read.key) && wanted.size >= MAX_QUERIES) {
265
- drop(`The sandbox read more than ${MAX_QUERIES} queries`);
266
- continue;
267
- }
268
- wanted.set(read.key, {
269
- input: read.input,
270
- key: read.key,
271
- name: read.name
272
- });
273
- }
274
- return wanted;
275
- };
276
- const syncQueries = (wanted) => {
277
- for (const [key, unsubscribe] of observers) if (!wanted.has(key)) {
278
- unsubscribe();
279
- observers.delete(key);
280
- sent.delete(key);
281
- }
282
- for (const [key, read] of wanted) if (!observers.has(key)) observers.set(key, client.observe(read.name, read.input, (result) => {
283
- sendQuery(key, result);
284
- }));
285
- };
286
- /** Marks the call `answered` done; returns whether it was a handler call. */
287
- const answer = (answered) => {
288
- const node = calls.get(answered);
289
- if (node === void 0) return false;
290
- calls.delete(answered);
291
- const left = (openCalls.get(node) ?? 1) - 1;
292
- if (left > 0) openCalls.set(node, left);
293
- else {
294
- openCalls.delete(node);
295
- settled.set(node, (settled.get(node) ?? 0) + 1);
296
- }
297
- return true;
298
- };
299
- const forgetCalls = () => {
300
- calls.clear();
301
- openCalls.clear();
302
- settled.clear();
303
- };
304
- const runMutations = (mutations, fromHandler) => {
305
- if (mutations.length > 0 && !fromHandler) {
306
- drop("The sandbox asked for a mutation outside an event handler");
307
- return;
308
- }
309
- if (mutations.length > MAX_MUTATIONS) drop(`The sandbox asked for more than ${MAX_MUTATIONS} mutations at once`);
310
- const started = generation;
311
- for (const mutation of mutations.slice(0, MAX_MUTATIONS)) {
312
- const call = isRecord(mutation) && typeof mutation.id === "number" && typeof mutation.name === "string" ? {
313
- id: mutation.id,
314
- input: mutation.input,
315
- name: mutation.name
316
- } : null;
317
- const entry = call === null ? void 0 : procedure(call.name, ["action", "mutation"]);
318
- if (call === null || entry === void 0) {
319
- drop("The sandbox called a mutation this program does not declare");
320
- continue;
321
- }
322
- const kind = entry.kind === "action" ? "action" : "mutation";
323
- client.mutate(call.name, call.input, {
324
- confirm: confirmHandler,
325
- invalidates: entry.invalidates,
326
- kind,
327
- signal: confirmationScope.signal
328
- }).then((result) => kind === "action" && result.status === "success" && started === generation ? runAction({
329
- input: result.data,
330
- name: call.name
331
- }) : result).then((result) => {
332
- if (started === generation) {
333
- seq++;
334
- post({
335
- id: call.id,
336
- result,
337
- seq,
338
- type: "mutation"
339
- });
340
- }
341
- });
342
- }
343
- };
344
- const onMessage = (event) => {
345
- if (iframe === null || event.source !== iframe.contentWindow) return;
346
- const message = event.data;
347
- if (!isFrameMessage(message)) return;
348
- switch (message.type) {
349
- case "unprotected":
350
- reportFrame(unprotectedProblem(client.frame));
351
- return;
352
- case "ready": {
353
- clearStartup();
354
- ready = true;
355
- const queued = queue;
356
- queue = [];
357
- for (const item of queued) post(item);
358
- return;
359
- }
360
- case "result": {
361
- const update = updates.get(message.seq);
362
- updates.delete(message.seq);
363
- if (update !== void 0) {
364
- running = update;
365
- lastGood = update;
366
- dropped = [];
367
- store.setSchemas(update);
368
- }
369
- store.apply(message.ops);
370
- const fromHandler = answer(message.seq);
371
- const wanted = checkedReads(message.queries);
372
- runMutations(message.mutations, fromHandler);
373
- const newest = update !== void 0 && update === latest && !hangs.has(update);
374
- setStatus({
375
- errors: parseErrors(message.errors),
376
- issues: [...store.getIssues(), ...dropped],
377
- notice: newest ? null : status.notice
378
- });
379
- syncQueries(wanted);
380
- return;
381
- }
382
- case "failed": {
383
- const failed = updates.get(message.seq);
384
- updates.delete(message.seq);
385
- answer(message.seq);
386
- if (failed !== void 0 && failed === latest) latest = running;
387
- setStatus({
388
- ...status,
389
- notice: `Program failed (${message.message}); showing the last working version.`
390
- });
391
- return;
392
- }
393
- case "quarantined": {
394
- const failed = updates.get(message.seq);
395
- const culprit = failed ?? running ?? latest;
396
- updates.clear();
397
- generation++;
398
- withdrawConfirmations();
399
- dropObservers();
400
- forgetCalls();
401
- store.reset();
402
- running = null;
403
- if (culprit !== null) {
404
- const before = hangs.get(culprit) ?? 0;
405
- hangs.set(culprit, failed === void 0 ? before + 1 : MAX_HANGS);
406
- if (before === 0) console.warn(`Pickle: ${WATCHDOG_NOTICE}`);
407
- }
408
- const usable = (candidate) => candidate !== null && (hangs.get(candidate) ?? 0) < MAX_HANGS;
409
- let recovery = null;
410
- if (usable(latest)) recovery = latest;
411
- else if (usable(lastGood)) recovery = lastGood;
412
- setStatus({
413
- errors: [],
414
- issues: [],
415
- notice: recovery === null ? GAVE_UP_NOTICE : WATCHDOG_NOTICE
416
- });
417
- if (recovery !== null) sendUpdate(recovery, false);
418
- return;
419
- }
420
- }
421
- };
422
- return {
423
- call: (node, fn, args) => {
424
- seq++;
425
- calls.set(seq, node);
426
- openCalls.set(node, (openCalls.get(node) ?? 0) + 1);
427
- post({
428
- args,
429
- fn,
430
- seq,
431
- type: "call"
432
- });
433
- },
434
- childrenAllowed: (type) => running !== null && Object.hasOwn(running.components, type) && running.components[type]?.children !== "none",
435
- getSettled: (node) => settled.get(node) ?? 0,
436
- getStatus: () => status,
437
- load: (next, document) => {
438
- const fresh = document !== latestDocument;
439
- const key = JSON.stringify(next);
440
- if (key === latestKey && !fresh) return;
441
- latestKey = key;
442
- const checked = parseProgram(next);
443
- if (checked === null) {
444
- setStatus({
445
- ...status,
446
- notice: FORMAT_NOTICE
447
- });
448
- return;
449
- }
450
- latest = checked;
451
- latestDocument = document;
452
- if (fresh) withdrawConfirmations();
453
- sendUpdate(checked, fresh);
454
- },
455
- propKind: (type, name) => {
456
- const component = running !== null && Object.hasOwn(running.components, type) ? running.components[type] : void 0;
457
- return component !== void 0 && Object.hasOwn(component.props, name) ? component.props[name]?.kind : void 0;
458
- },
459
- setActionHandler: (handler) => {
460
- actionHandler = handler;
461
- },
462
- setConfirmHandler: (handler) => {
463
- confirmHandler = handler;
464
- },
465
- setIssueHandler: (handler) => {
466
- issueHandler = handler;
467
- },
468
- setUnregistered: (issues) => {
469
- unregistered = issues;
470
- announce();
471
- },
472
- start: () => {
473
- if (iframe !== null) return;
474
- const frame = document.createElement("iframe");
475
- frame.setAttribute("sandbox", "allow-scripts");
476
- frame.setAttribute("aria-hidden", "true");
477
- frame.setAttribute("tabindex", "-1");
478
- frame.style.display = "none";
479
- frame.src = client.frame;
480
- frame.addEventListener("load", () => {
481
- if (iframe === frame && !ready && startup === null) startup = setTimeout(diagnoseFrame, STARTUP_MS);
482
- });
483
- iframe = frame;
484
- window.addEventListener("message", onMessage);
485
- document.body.append(frame);
486
- },
487
- stop: () => {
488
- generation++;
489
- window.removeEventListener("message", onMessage);
490
- iframe?.remove();
491
- iframe = null;
492
- ready = false;
493
- clearStartup();
494
- reported = false;
495
- queue = [];
496
- updates.clear();
497
- hangs = /* @__PURE__ */ new WeakMap();
498
- forgetCalls();
499
- dropped = [];
500
- lastGood = null;
501
- running = null;
502
- latest = null;
503
- latestKey = null;
504
- latestDocument = void 0;
505
- actionHandler = void 0;
506
- confirmHandler = void 0;
507
- withdrawConfirmations();
508
- dropObservers();
509
- store.reset();
510
- status = idle;
511
- },
512
- store,
513
- subscribe: (listener) => {
514
- listeners.add(listener);
515
- const unsubscribe = store.subscribe(listener);
516
- return () => {
517
- listeners.delete(listener);
518
- unsubscribe();
519
- };
520
- }
521
- };
522
- }
523
- //#endregion
524
- export { createViewController };
@@ -1,32 +0,0 @@
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 };