@jigs.design/react 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +51 -2
- package/dist/index.d.ts +9 -0
- package/dist/index.js +4 -0
- package/dist/view/PickleView.d.ts +64 -0
- package/dist/view/PickleView.js +125 -0
- package/dist/view/controller.d.ts +22 -0
- package/dist/view/controller.js +524 -0
- package/dist/view/frame-check.js +32 -0
- package/dist/view/registry.d.ts +21 -0
- package/dist/view/session.d.ts +21 -0
- package/dist/view/session.js +141 -0
- package/dist/view/settled.d.ts +11 -0
- package/dist/view/settled.js +17 -0
- package/dist/view/usePickleSession.d.ts +31 -0
- package/dist/view/usePickleSession.js +29 -0
- package/package.json +31 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tom Bates
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,52 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @jigs.design/react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The web renderer. `PickleView` runs a compiled program in a sandboxed frame and draws it with your registry. `usePickleSession` holds a conversation with the server and streams its latest program.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import { components } from "@jigs.design/catalog/react";
|
|
7
|
+
import { PickleView, usePickleSession } from "@jigs.design/react";
|
|
8
|
+
import { client } from "./pickle-client";
|
|
9
|
+
|
|
10
|
+
export function App() {
|
|
11
|
+
const session = usePickleSession(client);
|
|
12
|
+
return (
|
|
13
|
+
<>
|
|
14
|
+
<button onClick={() => session.send("My open orders")}>Ask</button>
|
|
15
|
+
{session.program && (
|
|
16
|
+
<PickleView
|
|
17
|
+
client={client}
|
|
18
|
+
components={components}
|
|
19
|
+
documentId={session.documentId}
|
|
20
|
+
program={session.program}
|
|
21
|
+
/>
|
|
22
|
+
)}
|
|
23
|
+
</>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The program runs in a worker inside a frame served by your Pickle route. The frame has no network: queries and mutations go through the client, and every prop is checked against the program's schema before it reaches your component. A new `program` replaces the running one and keeps the state it still declares. `onAction` handles the program's actions after the server checked their input. The view drops an `autoFocus` prop unless you pass `allowAutoFocus`, so a pickle streaming into a chat cannot take the focus. It checks what the worker asks for against the program, since the worker is not trusted.
|
|
29
|
+
|
|
30
|
+
## Exports
|
|
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`.
|
|
41
|
+
|
|
42
|
+
## Source
|
|
43
|
+
|
|
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.
|
|
47
|
+
- `src/view/registry.ts`, `src/view/settled.ts`: the registry types and `useHandlerSettled`.
|
|
48
|
+
- `src/view/session.ts`, `src/view/usePickleSession.ts`: the session store and its hook.
|
|
49
|
+
|
|
50
|
+
## Docs
|
|
51
|
+
|
|
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).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ViewIssue } from "./view/controller.js";
|
|
2
|
+
import { Registry, RegistryFor } from "./view/registry.js";
|
|
3
|
+
import { PickleView } from "./view/PickleView.js";
|
|
4
|
+
import { useHandlerSettled } from "./view/settled.js";
|
|
5
|
+
import { SessionState } from "./view/session.js";
|
|
6
|
+
import { PickleSession, usePickleSession } from "./view/usePickleSession.js";
|
|
7
|
+
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 };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { ViewIssue } from "./controller.js";
|
|
2
|
+
import { Registry } from "./registry.js";
|
|
3
|
+
import { Program } from "@jigs.design/runtime";
|
|
4
|
+
import { ConfirmFunction, PickleClient } from "@jigs.design/client";
|
|
5
|
+
//#region src/view/PickleView.d.ts
|
|
6
|
+
/** An action as `onAction` receives it; type it with `ActionEvent<typeof host>`. */
|
|
7
|
+
interface ActionLike {
|
|
8
|
+
input: unknown;
|
|
9
|
+
name: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Runs one program in the sandbox and draws it with `components`. A new
|
|
13
|
+
* `program` replaces the running one; state the new program still declares
|
|
14
|
+
* keeps its value, unless `documentId` changed with it. A new `client`
|
|
15
|
+
* starts the program over on that client.
|
|
16
|
+
*/
|
|
17
|
+
export declare function PickleView<TAction extends ActionLike = ActionLike>(props: {
|
|
18
|
+
/**
|
|
19
|
+
* Let the program focus a control when it appears, through the
|
|
20
|
+
* `autoFocus` prop. Default false: in a chat, a UI that streams in would
|
|
21
|
+
* otherwise take the focus from the composer while the user types.
|
|
22
|
+
*/
|
|
23
|
+
allowAutoFocus?: boolean;
|
|
24
|
+
client: PickleClient;
|
|
25
|
+
components: Registry;
|
|
26
|
+
/**
|
|
27
|
+
* Which UI `program` is a version of. When it changes, the program starts
|
|
28
|
+
* from fresh state instead of keeping the state of the one before. Pass
|
|
29
|
+
* `session.documentId`, which changes when the model writes a new UI
|
|
30
|
+
* rather than editing the last one. Leave it out to keep state across
|
|
31
|
+
* every program, as for edits.
|
|
32
|
+
*/
|
|
33
|
+
documentId?: number | string;
|
|
34
|
+
/**
|
|
35
|
+
* Handles the program's actions, after the server checked their input.
|
|
36
|
+
* What it returns, awaited and copied with `structuredClone`, is the
|
|
37
|
+
* handle's `data`; what it throws is its `error`. Without it, every
|
|
38
|
+
* action fails. It is not called once the view has unmounted.
|
|
39
|
+
*/
|
|
40
|
+
onAction?: (action: TAction) => unknown;
|
|
41
|
+
/**
|
|
42
|
+
* Asks the user to approve a mutation whose policy is "confirm", with the
|
|
43
|
+
* host's `message`, in the page's own UI: the sandboxed program never
|
|
44
|
+
* draws or answers it. Resolve true to run the mutation; false, or a
|
|
45
|
+
* throw, fails it with `DECLINED`. Without it, the client's `confirm` asks.
|
|
46
|
+
*/
|
|
47
|
+
onConfirm?: ConfirmFunction;
|
|
48
|
+
/**
|
|
49
|
+
* Called once for each new issue: a runtime error, something the view
|
|
50
|
+
* dropped, or a sandbox notice. An issue that persists is reported once;
|
|
51
|
+
* one that clears and comes back, again. It runs whether or not
|
|
52
|
+
* `showIssues` is on, so an app can count issues where users don't see
|
|
53
|
+
* them.
|
|
54
|
+
*/
|
|
55
|
+
onIssue?: (issue: ViewIssue) => void;
|
|
56
|
+
program: Program;
|
|
57
|
+
/**
|
|
58
|
+
* List runtime errors, dropped props and sandbox notices under the tree.
|
|
59
|
+
* Default true; turn it off where end users should not see them.
|
|
60
|
+
*/
|
|
61
|
+
showIssues?: boolean;
|
|
62
|
+
}): import("react").JSX.Element;
|
|
63
|
+
//#endregion
|
|
64
|
+
export {};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createViewController } from "./controller.js";
|
|
2
|
+
import { SettledContext } from "./settled.js";
|
|
3
|
+
import { ROOT_ID } from "@jigs.design/runtime";
|
|
4
|
+
import { useEffect, useState, useSyncExternalStore } from "react";
|
|
5
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
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
|
+
function TreeNodeView(props) {
|
|
11
|
+
const { components, controller } = props;
|
|
12
|
+
const getNode = () => controller.store.get(props.id);
|
|
13
|
+
const node = useSyncExternalStore(controller.subscribe, getNode, getNode);
|
|
14
|
+
const getSettled = () => controller.getSettled(props.id);
|
|
15
|
+
const settled = useSyncExternalStore(controller.subscribe, getSettled, getSettled);
|
|
16
|
+
if (node === void 0) return null;
|
|
17
|
+
if (node.type === "#text") return node.text;
|
|
18
|
+
const children = node.children.map((child) => /* @__PURE__ */ jsx(TreeNodeView, {
|
|
19
|
+
allowAutoFocus: props.allowAutoFocus,
|
|
20
|
+
components,
|
|
21
|
+
controller,
|
|
22
|
+
id: child
|
|
23
|
+
}, child));
|
|
24
|
+
if (node.type === "#root") return children;
|
|
25
|
+
const registered = Object.hasOwn(components, node.type) ? components[node.type] : void 0;
|
|
26
|
+
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
|
+
}
|
|
42
|
+
return /* @__PURE__ */ jsx(SettledContext, {
|
|
43
|
+
value: settled,
|
|
44
|
+
children: /* @__PURE__ */ jsx(registered, {
|
|
45
|
+
...drawn,
|
|
46
|
+
children: controller.childrenAllowed(node.type) && children.length > 0 ? children : void 0
|
|
47
|
+
})
|
|
48
|
+
});
|
|
49
|
+
}
|
|
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
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Runs one program in the sandbox and draws it with `components`. A new
|
|
55
|
+
* `program` replaces the running one; state the new program still declares
|
|
56
|
+
* keeps its value, unless `documentId` changed with it. A new `client`
|
|
57
|
+
* starts the program over on that client.
|
|
58
|
+
*/
|
|
59
|
+
function PickleView(props) {
|
|
60
|
+
const { program } = props;
|
|
61
|
+
const [view, setView] = useState(() => ({
|
|
62
|
+
client: props.client,
|
|
63
|
+
controller: createViewController(props.client)
|
|
64
|
+
}));
|
|
65
|
+
let { controller } = view;
|
|
66
|
+
if (view.client !== props.client) {
|
|
67
|
+
controller = createViewController(props.client);
|
|
68
|
+
setView({
|
|
69
|
+
client: props.client,
|
|
70
|
+
controller
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const status = useSyncExternalStore(controller.subscribe, controller.getStatus, controller.getStatus);
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
controller.setIssueHandler(props.onIssue);
|
|
76
|
+
}, [controller, props.onIssue]);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
controller.start();
|
|
79
|
+
return () => controller.stop();
|
|
80
|
+
}, [controller]);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
controller.setUnregistered(missingFromRegistry(program, props.components));
|
|
83
|
+
}, [
|
|
84
|
+
controller,
|
|
85
|
+
program,
|
|
86
|
+
props.components
|
|
87
|
+
]);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const handler = props.onAction;
|
|
90
|
+
controller.setActionHandler(handler);
|
|
91
|
+
}, [controller, props.onAction]);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
controller.setConfirmHandler(props.onConfirm);
|
|
94
|
+
}, [controller, props.onConfirm]);
|
|
95
|
+
const documentId = props.documentId;
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
controller.load(program, documentId);
|
|
98
|
+
}, [
|
|
99
|
+
controller,
|
|
100
|
+
program,
|
|
101
|
+
documentId
|
|
102
|
+
]);
|
|
103
|
+
const showIssues = props.showIssues ?? true;
|
|
104
|
+
const issues = showIssues ? [...missingFromRegistry(program, props.components), ...status.issues] : [];
|
|
105
|
+
const errors = showIssues ? status.errors : [];
|
|
106
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
107
|
+
/* @__PURE__ */ jsx(TreeNodeView, {
|
|
108
|
+
allowAutoFocus: props.allowAutoFocus === true,
|
|
109
|
+
components: props.components,
|
|
110
|
+
controller,
|
|
111
|
+
id: ROOT_ID
|
|
112
|
+
}),
|
|
113
|
+
showIssues && status.notice !== null && /* @__PURE__ */ jsx("p", {
|
|
114
|
+
className: "pickle-view-notice",
|
|
115
|
+
role: "status",
|
|
116
|
+
children: status.notice
|
|
117
|
+
}),
|
|
118
|
+
errors.length + issues.length > 0 && /* @__PURE__ */ jsxs("ul", {
|
|
119
|
+
className: "pickle-view-errors",
|
|
120
|
+
children: [errors.map((error, index) => /* @__PURE__ */ jsxs("li", { children: [error.line === null ? "" : `Line ${error.line}: `, error.message] }, `error:${index}:${error.message}`)), issues.map((issue) => /* @__PURE__ */ jsx("li", { children: issue }, `issue:${issue}`))]
|
|
121
|
+
})
|
|
122
|
+
] });
|
|
123
|
+
}
|
|
124
|
+
//#endregion
|
|
125
|
+
export { PickleView };
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
|
@@ -0,0 +1,524 @@
|
|
|
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 };
|
|
@@ -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 };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ComponentType, ReactNode } from "react";
|
|
2
|
+
//#region src/view/registry.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* One React component per catalog name. Props reach a component only after
|
|
5
|
+
* they passed the schema the program carries, so each component declares the
|
|
6
|
+
* props it reads; `never` lets any such component into the map.
|
|
7
|
+
*/
|
|
8
|
+
export type Registry = Readonly<Record<string, ComponentType<never>>>;
|
|
9
|
+
/**
|
|
10
|
+
* A registry checked against a catalog's props: every name present, each
|
|
11
|
+
* component accepting what the catalog may pass. Use it with
|
|
12
|
+
* `CatalogProps` from `@jigs.design/host`:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* export const components = { … } satisfies RegistryFor<CatalogProps<typeof catalog>>;
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export type RegistryFor<TProps extends Readonly<Record<string, object>>> = { readonly [K in keyof TProps]: (props: TProps[K] & {
|
|
19
|
+
children?: ReactNode;
|
|
20
|
+
}) => ReactNode; };
|
|
21
|
+
//#endregion
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Diagnostic, Message, PickleErrorData, Program, SessionOptions } from "@jigs.design/runtime";
|
|
2
|
+
import { PickleClient } from "@jigs.design/client";
|
|
3
|
+
//#region src/view/session.d.ts
|
|
4
|
+
export interface SessionState {
|
|
5
|
+
diagnostics: ReadonlyArray<Diagnostic>;
|
|
6
|
+
/**
|
|
7
|
+
* Which UI `program` is a version of. It goes up when the model writes a
|
|
8
|
+
* new UI rather than editing the last one, or a stored one is restored,
|
|
9
|
+
* and goes back with the program when a turn is stopped. Pass it to
|
|
10
|
+
* `PickleView` as `documentId`, so a new UI starts from fresh state.
|
|
11
|
+
*/
|
|
12
|
+
documentId: number;
|
|
13
|
+
/** `{ code, message }` when `status` is "error". */
|
|
14
|
+
error: PickleErrorData | null;
|
|
15
|
+
/** The conversation, to store or render. Assistant entries hold checked source. */
|
|
16
|
+
messages: ReadonlyArray<Message>;
|
|
17
|
+
/** The latest program, or null before the first one arrives. */
|
|
18
|
+
program: Program | null;
|
|
19
|
+
status: "error" | "idle" | "streaming";
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
//#region src/view/session.ts
|
|
2
|
+
const endedEarly = {
|
|
3
|
+
code: "NETWORK",
|
|
4
|
+
message: "The turn ended before it finished."
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* The conversation and the latest program for one chat. A turn that ends
|
|
8
|
+
* without `done` leaves no trace: the messages and the program go back to
|
|
9
|
+
* where they were before it started.
|
|
10
|
+
*/
|
|
11
|
+
function createSessionStore(initialClient, initialMessages) {
|
|
12
|
+
let client = initialClient;
|
|
13
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
14
|
+
let committed = {
|
|
15
|
+
diagnostics: [],
|
|
16
|
+
documentId: 0,
|
|
17
|
+
messages: initialMessages,
|
|
18
|
+
program: null
|
|
19
|
+
};
|
|
20
|
+
let state = {
|
|
21
|
+
...committed,
|
|
22
|
+
error: null,
|
|
23
|
+
status: "idle"
|
|
24
|
+
};
|
|
25
|
+
let active = null;
|
|
26
|
+
const set = (next) => {
|
|
27
|
+
state = next;
|
|
28
|
+
for (const listener of listeners) listener();
|
|
29
|
+
};
|
|
30
|
+
const rollback = (error) => {
|
|
31
|
+
set({
|
|
32
|
+
...committed,
|
|
33
|
+
error,
|
|
34
|
+
status: error === null ? "idle" : "error"
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
const run = async (messages, options, turn) => {
|
|
38
|
+
const body = options === void 0 ? { messages } : {
|
|
39
|
+
messages,
|
|
40
|
+
options
|
|
41
|
+
};
|
|
42
|
+
try {
|
|
43
|
+
for await (const event of client.session(body, { signal: turn.signal })) {
|
|
44
|
+
if (turn !== active) return;
|
|
45
|
+
if (event.type === "program") {
|
|
46
|
+
set({
|
|
47
|
+
...state,
|
|
48
|
+
diagnostics: event.diagnostics,
|
|
49
|
+
documentId: event.fresh ? state.documentId + 1 : state.documentId,
|
|
50
|
+
program: event.program
|
|
51
|
+
});
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (event.type === "error") {
|
|
55
|
+
active = null;
|
|
56
|
+
rollback(event.error);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const last = messages.at(-1);
|
|
60
|
+
committed = {
|
|
61
|
+
diagnostics: event.diagnostics,
|
|
62
|
+
documentId: state.documentId,
|
|
63
|
+
messages: last?.role === "assistant" ? messages : [...messages, {
|
|
64
|
+
content: event.source,
|
|
65
|
+
role: "assistant"
|
|
66
|
+
}],
|
|
67
|
+
program: state.program
|
|
68
|
+
};
|
|
69
|
+
active = null;
|
|
70
|
+
set({
|
|
71
|
+
...committed,
|
|
72
|
+
error: null,
|
|
73
|
+
status: "idle"
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
} catch {}
|
|
78
|
+
if (turn === active) {
|
|
79
|
+
active = null;
|
|
80
|
+
rollback(endedEarly);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const begin = (messages, options) => {
|
|
84
|
+
const turn = new AbortController();
|
|
85
|
+
active = turn;
|
|
86
|
+
set({
|
|
87
|
+
...state,
|
|
88
|
+
error: null,
|
|
89
|
+
messages,
|
|
90
|
+
status: "streaming"
|
|
91
|
+
});
|
|
92
|
+
run(messages, options, turn);
|
|
93
|
+
};
|
|
94
|
+
const stop = () => {
|
|
95
|
+
if (active === null) return;
|
|
96
|
+
active.abort();
|
|
97
|
+
active = null;
|
|
98
|
+
rollback(null);
|
|
99
|
+
};
|
|
100
|
+
const resume = () => {
|
|
101
|
+
if (active === null && committed.messages.at(-1)?.role === "assistant") begin(committed.messages, void 0);
|
|
102
|
+
};
|
|
103
|
+
return {
|
|
104
|
+
getState: () => state,
|
|
105
|
+
reset: (messages = []) => {
|
|
106
|
+
stop();
|
|
107
|
+
committed = {
|
|
108
|
+
diagnostics: [],
|
|
109
|
+
documentId: committed.documentId,
|
|
110
|
+
messages,
|
|
111
|
+
program: null
|
|
112
|
+
};
|
|
113
|
+
set({
|
|
114
|
+
...committed,
|
|
115
|
+
error: null,
|
|
116
|
+
status: "idle"
|
|
117
|
+
});
|
|
118
|
+
resume();
|
|
119
|
+
},
|
|
120
|
+
resume,
|
|
121
|
+
send: (prompt, options) => {
|
|
122
|
+
stop();
|
|
123
|
+
begin([...committed.messages, {
|
|
124
|
+
content: prompt,
|
|
125
|
+
role: "user"
|
|
126
|
+
}], options);
|
|
127
|
+
},
|
|
128
|
+
setClient: (next) => {
|
|
129
|
+
client = next;
|
|
130
|
+
},
|
|
131
|
+
stop,
|
|
132
|
+
subscribe: (listener) => {
|
|
133
|
+
listeners.add(listener);
|
|
134
|
+
return () => {
|
|
135
|
+
listeners.delete(listener);
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
141
|
+
export { createSessionStore };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/view/settled.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Counts the times the program finished answering every handler call of the
|
|
4
|
+
* component being drawn: it changes once the program has seen the
|
|
5
|
+
* component's latest events and its answer is on screen. A control that
|
|
6
|
+
* runs ahead of the program, such as a text field showing what was typed,
|
|
7
|
+
* can then go back to its value prop if the program kept another value.
|
|
8
|
+
* Always 0 outside `PickleView`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function useHandlerSettled(): number;
|
|
11
|
+
//#endregion
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
//#region src/view/settled.ts
|
|
3
|
+
/** How many times the node being drawn had all its handler calls answered. */
|
|
4
|
+
const SettledContext = createContext(0);
|
|
5
|
+
/**
|
|
6
|
+
* Counts the times the program finished answering every handler call of the
|
|
7
|
+
* component being drawn: it changes once the program has seen the
|
|
8
|
+
* component's latest events and its answer is on screen. A control that
|
|
9
|
+
* runs ahead of the program, such as a text field showing what was typed,
|
|
10
|
+
* can then go back to its value prop if the program kept another value.
|
|
11
|
+
* Always 0 outside `PickleView`.
|
|
12
|
+
*/
|
|
13
|
+
function useHandlerSettled() {
|
|
14
|
+
return useContext(SettledContext);
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
export { SettledContext, useHandlerSettled };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { SessionState } from "./session.js";
|
|
2
|
+
import { Message, SessionOptions } from "@jigs.design/runtime";
|
|
3
|
+
import { PickleClient } from "@jigs.design/client";
|
|
4
|
+
//#region src/view/usePickleSession.d.ts
|
|
5
|
+
export interface PickleSession<TOptions extends SessionOptions = SessionOptions> extends SessionState {
|
|
6
|
+
/**
|
|
7
|
+
* Stops any turn and starts over with `messages` (default none). Like
|
|
8
|
+
* `initialMessages`, messages that end with an assistant message are
|
|
9
|
+
* compiled again without calling the model.
|
|
10
|
+
*/
|
|
11
|
+
reset: (messages?: ReadonlyArray<Message>) => void;
|
|
12
|
+
/**
|
|
13
|
+
* Starts a turn. A turn already streaming stops first. `options` go to the
|
|
14
|
+
* server's `options` schema; type them with the schema's input type.
|
|
15
|
+
*/
|
|
16
|
+
send: (prompt: string, options?: TOptions) => void;
|
|
17
|
+
/** Aborts the turn in flight; the conversation goes back to before it. */
|
|
18
|
+
stop: () => void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Holds a conversation with the server and its latest program. Pass
|
|
22
|
+
* `initialMessages` to resume a stored conversation: when they end with an
|
|
23
|
+
* assistant message, the server compiles it again without calling the model.
|
|
24
|
+
* `initialMessages` is read once, when the hook mounts; to load another
|
|
25
|
+
* conversation later, call `reset(messages)`. A new `client` stops the turn
|
|
26
|
+
* in flight and compiles the conversation's last answer again through it.
|
|
27
|
+
*/
|
|
28
|
+
export declare function usePickleSession<TOptions extends SessionOptions = SessionOptions>(client: PickleClient, options?: {
|
|
29
|
+
initialMessages?: ReadonlyArray<Message>;
|
|
30
|
+
}): PickleSession<TOptions>;
|
|
31
|
+
//#endregion
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createSessionStore } from "./session.js";
|
|
2
|
+
import { useEffect, useState, useSyncExternalStore } from "react";
|
|
3
|
+
//#region src/view/usePickleSession.ts
|
|
4
|
+
const NO_MESSAGES = [];
|
|
5
|
+
/**
|
|
6
|
+
* Holds a conversation with the server and its latest program. Pass
|
|
7
|
+
* `initialMessages` to resume a stored conversation: when they end with an
|
|
8
|
+
* assistant message, the server compiles it again without calling the model.
|
|
9
|
+
* `initialMessages` is read once, when the hook mounts; to load another
|
|
10
|
+
* conversation later, call `reset(messages)`. A new `client` stops the turn
|
|
11
|
+
* in flight and compiles the conversation's last answer again through it.
|
|
12
|
+
*/
|
|
13
|
+
function usePickleSession(client, options) {
|
|
14
|
+
const [store] = useState(() => createSessionStore(client, options?.initialMessages ?? NO_MESSAGES));
|
|
15
|
+
const state = useSyncExternalStore(store.subscribe, store.getState, store.getState);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
store.setClient(client);
|
|
18
|
+
store.resume();
|
|
19
|
+
return () => store.stop();
|
|
20
|
+
}, [store, client]);
|
|
21
|
+
return {
|
|
22
|
+
...state,
|
|
23
|
+
reset: store.reset,
|
|
24
|
+
send: store.send,
|
|
25
|
+
stop: store.stop
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
export { usePickleSession };
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jigs.design/react",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/yoamomonstruos/pickle.git",
|
|
9
|
+
"directory": "packages/react"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@jigs.design/runtime": "0.1.0",
|
|
29
|
+
"@jigs.design/client": "0.1.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
33
|
+
}
|
|
6
34
|
}
|