@optiaxiom/proteus 3.0.6 → 3.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/dist/esm/assets/src/proteus-chart/{ProteusChart.css.ts.vanilla-ByPSKREp.css → ProteusChart.css.ts.vanilla-BcGBUwbD.css} +2 -2
- package/dist/esm/assets/src/proteus-chart/{ProteusChartTooltipContent.css.ts.vanilla-DnR_C5ly.css → ProteusChartTooltipContent.css.ts.vanilla-BthlShlk.css} +2 -2
- package/dist/esm/assets/src/proteus-document/{ProteusDocumentShell.css.ts.vanilla-CVOes7es.css → ProteusDocumentShell.css.ts.vanilla-BugeP59E.css} +2 -2
- package/dist/esm/assets/src/proteus-image-carousel/{ProteusImageCarousel.css.ts.vanilla-DXF3wHYt.css → ProteusImageCarousel.css.ts.vanilla-DhXdg25-.css} +2 -2
- package/dist/esm/assets/src/proteus-markdown/{ProteusMarkdown.css.ts.vanilla-Cjmk3X5z.css → ProteusMarkdown.css.ts.vanilla-BM9-lPyj.css} +2 -2
- package/dist/esm/assets/src/proteus-question/{ProteusQuestion.css.ts.vanilla-DVFRsIPp.css → ProteusQuestion.css.ts.vanilla-1u353vmb.css} +2 -2
- package/dist/esm/proteus-action/ProteusAction.js +1 -1
- package/dist/esm/proteus-chart/ProteusChart-css.js +1 -1
- package/dist/esm/proteus-chart/ProteusChartTooltipContent-css.js +1 -1
- package/dist/esm/proteus-document/ProteusDocumentShell-css.js +1 -1
- package/dist/esm/proteus-document/ProteusDocumentShell.js +70 -49
- package/dist/esm/proteus-document/isSafeUrl.js +22 -0
- package/dist/esm/proteus-element/ProteusElement.js +10 -1
- package/dist/esm/proteus-image-carousel/ProteusImageCarousel-css.js +1 -1
- package/dist/esm/proteus-markdown/ProteusMarkdown-css.js +1 -1
- package/dist/esm/proteus-question/ProteusQuestion-css.js +1 -1
- package/dist/esm/proteus-script/useProteusScripts.js +164 -0
- package/dist/esm/proteus-script/workerScript.js +149 -0
- package/dist/esm/schema/public-schema.js +66 -0
- package/dist/esm/schema/runtime-schema.js +64 -0
- package/dist/index.d.ts +11 -0
- package/package.json +2 -2
|
@@ -33,7 +33,7 @@ function ProteusAction({ children, onClick, type = "button", ...props }) {
|
|
|
33
33
|
* available — so the document-root event handler can apply it unambiguously.
|
|
34
34
|
*/
|
|
35
35
|
function resolveEventPath(event, parentPath) {
|
|
36
|
-
if (event && typeof event === "object" && "action" in event && (event.action === "pushValue" || event.action === "removeValue")) {
|
|
36
|
+
if (event && typeof event === "object" && "action" in event && (event.action === "pushValue" || event.action === "removeValue" || event.action === "setValue")) {
|
|
37
37
|
const { path } = event;
|
|
38
38
|
const resolved = path === "" ? parentPath : path.startsWith("/") ? path : `${parentPath}/${path}`;
|
|
39
39
|
return {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import "./../assets/src/proteus-chart/ProteusChart.css.ts.vanilla-
|
|
1
|
+
import "./../assets/src/proteus-chart/ProteusChart.css.ts.vanilla-BcGBUwbD.css";
|
|
2
2
|
import { recipe } from "@optiaxiom/react/css-runtime";
|
|
3
3
|
//#region src/proteus-chart/ProteusChart.css.ts
|
|
4
4
|
var chart = recipe({ base: [
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { recipe } from "@optiaxiom/react/css-runtime";
|
|
2
|
-
import "./../assets/src/proteus-chart/ProteusChartTooltipContent.css.ts.vanilla-
|
|
2
|
+
import "./../assets/src/proteus-chart/ProteusChartTooltipContent.css.ts.vanilla-BthlShlk.css";
|
|
3
3
|
//#region src/proteus-chart/ProteusChartTooltipContent.css.ts
|
|
4
4
|
var tooltip = recipe({ base: [{
|
|
5
5
|
bg: "bg.default",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { recipe } from "@optiaxiom/react/css-runtime";
|
|
2
|
-
import "./../assets/src/proteus-document/ProteusDocumentShell.css.ts.vanilla-
|
|
2
|
+
import "./../assets/src/proteus-document/ProteusDocumentShell.css.ts.vanilla-BugeP59E.css";
|
|
3
3
|
//#region src/proteus-document/ProteusDocumentShell.css.ts
|
|
4
4
|
var body = recipe({
|
|
5
5
|
base: [{
|
|
@@ -3,6 +3,8 @@ import { ProteusDocumentProvider } from "./ProteusDocumentContext.js";
|
|
|
3
3
|
import { resolveProteusValue } from "./resolveProteusValue.js";
|
|
4
4
|
import { useEffectEvent } from "../hooks/useEffectEvent.js";
|
|
5
5
|
import { downloadFile } from "../proteus-image/downloadFile.js";
|
|
6
|
+
import { useProteusScripts } from "../proteus-script/useProteusScripts.js";
|
|
7
|
+
import { isSafeUrl } from "./isSafeUrl.js";
|
|
6
8
|
import { body, scrollIndicator } from "./ProteusDocumentShell-css.js";
|
|
7
9
|
import { Box, Disclosure, DisclosureContent, DisclosureTrigger, Group, Heading, Text, Tooltip } from "@optiaxiom/react";
|
|
8
10
|
import { useEffect, useRef, useState } from "react";
|
|
@@ -41,6 +43,73 @@ function ProteusDocumentShell({ collapsible: collapsibleProp, data = {}, default
|
|
|
41
43
|
const collapsible = collapsibleProp && element.appName;
|
|
42
44
|
const Trigger = collapsible ? DisclosureTrigger : Box;
|
|
43
45
|
const inline = resolveProteusValue(element.appearance, data, "", []) === "inline";
|
|
46
|
+
const onEvent = useEffectEvent(async (event) => {
|
|
47
|
+
if ("interaction" in event) return await onInteraction?.(event.interaction, event.params);
|
|
48
|
+
else if ("message" in event) await onMessage?.(event.message);
|
|
49
|
+
else if ("script" in event) return await runScript(event.script, event.params);
|
|
50
|
+
else if (event.action === "download") {
|
|
51
|
+
const urls = [];
|
|
52
|
+
if (typeof event.url === "string") urls.push(event.url);
|
|
53
|
+
else if (Array.isArray(event.url)) for (const u of event.url) {
|
|
54
|
+
if (typeof u !== "string") throw new Error("Invalid URL in download array");
|
|
55
|
+
urls.push(u);
|
|
56
|
+
}
|
|
57
|
+
else throw new Error("Invalid URL for download action");
|
|
58
|
+
for (const u of urls) if (!isSafeUrl(u)) {
|
|
59
|
+
if (strict) throw new Error(`download: unsafe URL "${u}"`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (onDownload) await onDownload(urls);
|
|
63
|
+
else await Promise.all(urls.map((u) => downloadFile(u)));
|
|
64
|
+
} else if (event.action === "openLink") {
|
|
65
|
+
if (isSafeUrl(event.url)) window.open(event.url, "_blank", "noopener,noreferrer");
|
|
66
|
+
else if (strict) throw new Error(`openLink: unsafe URL "${String(event.url)}"`);
|
|
67
|
+
} else if (event.action === "preview") await onPreview?.(event.file);
|
|
68
|
+
else if (event.action === "pushValue") {
|
|
69
|
+
const { path, value } = event;
|
|
70
|
+
onDataChange?.((prev) => {
|
|
71
|
+
const next = structuredClone(prev);
|
|
72
|
+
const current = get(next, path);
|
|
73
|
+
if (current !== void 0 && !Array.isArray(current)) {
|
|
74
|
+
if (strict) throw new Error(`pushValue: expected array at "${path}"`);
|
|
75
|
+
return prev;
|
|
76
|
+
}
|
|
77
|
+
set(next, path, [...current ?? [], value]);
|
|
78
|
+
return next;
|
|
79
|
+
});
|
|
80
|
+
} else if (event.action === "removeValue") {
|
|
81
|
+
const { path } = event;
|
|
82
|
+
const slash = path.lastIndexOf("/");
|
|
83
|
+
const parent = slash > 0 ? path.slice(0, slash) : "";
|
|
84
|
+
const index = Number(path.slice(slash + 1));
|
|
85
|
+
if (!parent || !Number.isInteger(index)) {
|
|
86
|
+
if (strict) throw new Error(`removeValue: "${path}" is not an array index`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
onDataChange?.((prev) => {
|
|
90
|
+
const next = structuredClone(prev);
|
|
91
|
+
const arr = get(next, parent);
|
|
92
|
+
if (!Array.isArray(arr)) {
|
|
93
|
+
if (strict) throw new Error(`removeValue: "${parent}" is not an array`);
|
|
94
|
+
return prev;
|
|
95
|
+
}
|
|
96
|
+
set(next, parent, arr.filter((_, i) => i !== index));
|
|
97
|
+
return next;
|
|
98
|
+
});
|
|
99
|
+
} else if (event.action === "setValue") {
|
|
100
|
+
const { path, value } = event;
|
|
101
|
+
onDataChange?.((prev) => {
|
|
102
|
+
const next = structuredClone(prev);
|
|
103
|
+
set(next, path, value);
|
|
104
|
+
return next;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
const runScript = useProteusScripts({
|
|
109
|
+
data,
|
|
110
|
+
onEmit: onEvent,
|
|
111
|
+
scripts: element.scripts
|
|
112
|
+
});
|
|
44
113
|
return /* @__PURE__ */ jsx(ProteusDocumentProvider, {
|
|
45
114
|
data,
|
|
46
115
|
icons,
|
|
@@ -51,55 +120,7 @@ function ProteusDocumentShell({ collapsible: collapsibleProp, data = {}, default
|
|
|
51
120
|
return next;
|
|
52
121
|
});
|
|
53
122
|
}),
|
|
54
|
-
onEvent
|
|
55
|
-
if ("interaction" in event) return await onInteraction?.(event.interaction, event.params);
|
|
56
|
-
else if ("message" in event) await onMessage?.(event.message);
|
|
57
|
-
else if (event.action === "download") {
|
|
58
|
-
const urls = [];
|
|
59
|
-
if (typeof event.url === "string") urls.push(event.url);
|
|
60
|
-
else if (Array.isArray(event.url)) for (const u of event.url) {
|
|
61
|
-
if (typeof u !== "string") throw new Error("Invalid URL in download array");
|
|
62
|
-
urls.push(u);
|
|
63
|
-
}
|
|
64
|
-
else throw new Error("Invalid URL for download action");
|
|
65
|
-
if (onDownload) await onDownload(urls);
|
|
66
|
-
else await Promise.all(urls.map((u) => downloadFile(u)));
|
|
67
|
-
} else if (event.action === "openLink") {
|
|
68
|
-
if (typeof event.url === "string") window.open(event.url, "_blank", "noopener,noreferrer");
|
|
69
|
-
} else if (event.action === "preview") await onPreview?.(event.file);
|
|
70
|
-
else if (event.action === "pushValue") {
|
|
71
|
-
const { path, value } = event;
|
|
72
|
-
onDataChange?.((prev) => {
|
|
73
|
-
const next = structuredClone(prev);
|
|
74
|
-
const current = get(next, path);
|
|
75
|
-
if (current !== void 0 && !Array.isArray(current)) {
|
|
76
|
-
if (strict) throw new Error(`pushValue: expected array at "${path}"`);
|
|
77
|
-
return prev;
|
|
78
|
-
}
|
|
79
|
-
set(next, path, [...current ?? [], value]);
|
|
80
|
-
return next;
|
|
81
|
-
});
|
|
82
|
-
} else if (event.action === "removeValue") {
|
|
83
|
-
const { path } = event;
|
|
84
|
-
const slash = path.lastIndexOf("/");
|
|
85
|
-
const parent = slash > 0 ? path.slice(0, slash) : "";
|
|
86
|
-
const index = Number(path.slice(slash + 1));
|
|
87
|
-
if (!parent || !Number.isInteger(index)) {
|
|
88
|
-
if (strict) throw new Error(`removeValue: "${path}" is not an array index`);
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
onDataChange?.((prev) => {
|
|
92
|
-
const next = structuredClone(prev);
|
|
93
|
-
const arr = get(next, parent);
|
|
94
|
-
if (!Array.isArray(arr)) {
|
|
95
|
-
if (strict) throw new Error(`removeValue: "${parent}" is not an array`);
|
|
96
|
-
return prev;
|
|
97
|
-
}
|
|
98
|
-
set(next, parent, arr.filter((_, i) => i !== index));
|
|
99
|
-
return next;
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
}),
|
|
123
|
+
onEvent,
|
|
103
124
|
onTrack: useEffectEvent((event, properties) => {
|
|
104
125
|
onTrack?.(event, properties);
|
|
105
126
|
}),
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/proteus-document/isSafeUrl.ts
|
|
2
|
+
/**
|
|
3
|
+
* Whether a URL is safe to hand to `window.open` from a document-triggered
|
|
4
|
+
* action (`openLink` / `download`).
|
|
5
|
+
*
|
|
6
|
+
* Documents — and now scripts, via `ctx.emit` — supply these URLs, so we only
|
|
7
|
+
* allow navigable web schemes and reject dangerous ones like `javascript:`
|
|
8
|
+
* (script injection) and `data:` (can host arbitrary HTML/JS in a new tab).
|
|
9
|
+
* Relative URLs are resolved against the current document.
|
|
10
|
+
*/
|
|
11
|
+
function isSafeUrl(url) {
|
|
12
|
+
if (typeof url !== "string") return false;
|
|
13
|
+
let parsed;
|
|
14
|
+
try {
|
|
15
|
+
parsed = new URL(url, window.location.href);
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
export { isSafeUrl };
|
|
@@ -82,7 +82,16 @@ const ProteusElement = ({ element: elementProp }) => {
|
|
|
82
82
|
const { filled, name, ...rest } = resolve(element);
|
|
83
83
|
const IconComp = icons?.[name];
|
|
84
84
|
if (!IconComp) {
|
|
85
|
-
if (
|
|
85
|
+
if (typeof name === "string" && /^(?:https?:\/\/|data:)/.test(name)) return /* @__PURE__ */ jsx(Box, {
|
|
86
|
+
asChild: true,
|
|
87
|
+
...rest,
|
|
88
|
+
children: /* @__PURE__ */ jsx("img", {
|
|
89
|
+
alt: "",
|
|
90
|
+
"aria-hidden": "true",
|
|
91
|
+
src: name
|
|
92
|
+
})
|
|
93
|
+
});
|
|
94
|
+
if (strict) throw new Error(`Icon "${name}" not registered and not a URL. Pass it via the \`icons\` prop on ProteusDocumentRenderer, or use an http(s):// or data: URL.`);
|
|
86
95
|
return null;
|
|
87
96
|
}
|
|
88
97
|
return /* @__PURE__ */ jsx(Box, {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { recipe } from "@optiaxiom/react/css-runtime";
|
|
2
|
-
import "./../assets/src/proteus-image-carousel/ProteusImageCarousel.css.ts.vanilla-
|
|
2
|
+
import "./../assets/src/proteus-image-carousel/ProteusImageCarousel.css.ts.vanilla-DhXdg25-.css";
|
|
3
3
|
//#region src/proteus-image-carousel/ProteusImageCarousel.css.ts
|
|
4
4
|
var carousel = recipe({ base: [{
|
|
5
5
|
flexDirection: "column",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { recipe } from "@optiaxiom/react/css-runtime";
|
|
2
|
-
import "./../assets/src/proteus-markdown/ProteusMarkdown.css.ts.vanilla-
|
|
2
|
+
import "./../assets/src/proteus-markdown/ProteusMarkdown.css.ts.vanilla-BM9-lPyj.css";
|
|
3
3
|
//#region src/proteus-markdown/ProteusMarkdown.css.ts
|
|
4
4
|
var code = recipe({ base: [{
|
|
5
5
|
bg: "bg.secondary",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { recipe } from "@optiaxiom/react/css-runtime";
|
|
2
|
-
import "./../assets/src/proteus-question/ProteusQuestion.css.ts.vanilla-
|
|
2
|
+
import "./../assets/src/proteus-question/ProteusQuestion.css.ts.vanilla-1u353vmb.css";
|
|
3
3
|
//#region src/proteus-question/ProteusQuestion.css.ts
|
|
4
4
|
var addon = recipe({
|
|
5
5
|
base: [{
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use client";
|
|
3
|
+
import { useEffectEvent } from "../hooks/useEffectEvent.js";
|
|
4
|
+
import { WORKER_SCRIPT } from "./workerScript.js";
|
|
5
|
+
import { useEffect, useRef } from "react";
|
|
6
|
+
import { get } from "jsonpointer";
|
|
7
|
+
//#region src/proteus-script/useProteusScripts.ts
|
|
8
|
+
/**
|
|
9
|
+
* How long a single handler run may take before we give up on it. A script
|
|
10
|
+
* with an infinite loop cannot block the main thread (it runs in the worker),
|
|
11
|
+
* but its `invoke` promise would otherwise never resolve — wedging the
|
|
12
|
+
* triggering control in a `loading` state forever. On timeout we resolve the
|
|
13
|
+
* run to `undefined` (the fail-silent default) and terminate the worker so the
|
|
14
|
+
* runaway handler stops burning a core; the next run lazily respawns it.
|
|
15
|
+
*/
|
|
16
|
+
const INVOKE_TIMEOUT_MS = 5e3;
|
|
17
|
+
/**
|
|
18
|
+
* Spins up a single sandbox Web Worker for the document's `scripts` and returns
|
|
19
|
+
* a `runScript(handler, params)` that invokes a named handler inside it. Events
|
|
20
|
+
* a handler or watcher emits are routed back through `onEmit` (the shell
|
|
21
|
+
* dispatcher) and their results returned to the worker, so `ctx.emit`
|
|
22
|
+
* round-trips work.
|
|
23
|
+
*
|
|
24
|
+
* Watchers (`watch(path, fn)` in a script) are driven from here: the worker
|
|
25
|
+
* reports its watched paths after `init`, and this hook edge-detects each path
|
|
26
|
+
* against `data` on every change, posting `runWatcher` when the value there
|
|
27
|
+
* changes. A watcher does NOT re-fire on data changes caused by a script's own
|
|
28
|
+
* `emit` (see `emitDepthRef`) — watchers observe user/host state changes, which
|
|
29
|
+
* both prevents emit→watcher→emit loops and gives a predictable "reacts to what
|
|
30
|
+
* the user did" semantic.
|
|
31
|
+
*
|
|
32
|
+
* Returns a no-op runner when the document ships no scripts — the worker is
|
|
33
|
+
* spawned eagerly when scripts exist (watchers can fire before any interaction).
|
|
34
|
+
*/
|
|
35
|
+
function useProteusScripts({ data, onEmit, scripts }) {
|
|
36
|
+
const dataRef = useRef(data);
|
|
37
|
+
dataRef.current = data;
|
|
38
|
+
const emit = useEffectEvent(onEmit);
|
|
39
|
+
const hasScripts = !!scripts && Object.keys(scripts).length > 0;
|
|
40
|
+
const pending = useRef(/* @__PURE__ */ new Map()).current;
|
|
41
|
+
const nextId = useRef(1);
|
|
42
|
+
const workerRef = useRef(null);
|
|
43
|
+
const watchedPaths = useRef([]);
|
|
44
|
+
const lastEval = useRef([]);
|
|
45
|
+
const emitDepth = useRef(0);
|
|
46
|
+
/** Snapshot every watched path from `data` into `lastEval` without firing. */
|
|
47
|
+
const seedWatchers = useEffectEvent((data) => {
|
|
48
|
+
lastEval.current = watchedPaths.current.map((path) => JSON.stringify(getByPointer(data, path) ?? null));
|
|
49
|
+
});
|
|
50
|
+
/** Resolve a pending run (if still pending) and clear its timeout. */
|
|
51
|
+
const settle = useEffectEvent((invokeId, result) => {
|
|
52
|
+
const entry = pending.get(invokeId);
|
|
53
|
+
if (!entry) return;
|
|
54
|
+
clearTimeout(entry.timer);
|
|
55
|
+
pending.delete(invokeId);
|
|
56
|
+
entry.resolve(result);
|
|
57
|
+
});
|
|
58
|
+
const spawnWorker = useEffectEvent(() => {
|
|
59
|
+
const url = URL.createObjectURL(new Blob([WORKER_SCRIPT], { type: "text/javascript" }));
|
|
60
|
+
const instance = new Worker(url);
|
|
61
|
+
URL.revokeObjectURL(url);
|
|
62
|
+
instance.postMessage({
|
|
63
|
+
scripts,
|
|
64
|
+
type: "init"
|
|
65
|
+
});
|
|
66
|
+
instance.addEventListener("message", async (e) => {
|
|
67
|
+
const msg = e.data;
|
|
68
|
+
if (msg.type === "emit") {
|
|
69
|
+
emitDepth.current += 1;
|
|
70
|
+
let result;
|
|
71
|
+
try {
|
|
72
|
+
result = await emit(msg.event);
|
|
73
|
+
} finally {
|
|
74
|
+
emitDepth.current -= 1;
|
|
75
|
+
}
|
|
76
|
+
instance.postMessage({
|
|
77
|
+
emitId: msg.emitId,
|
|
78
|
+
result,
|
|
79
|
+
type: "emitResult"
|
|
80
|
+
});
|
|
81
|
+
} else if (msg.type === "invokeResult") settle(msg.invokeId, msg.result);
|
|
82
|
+
else if (msg.type === "watchers") {
|
|
83
|
+
watchedPaths.current = msg.paths;
|
|
84
|
+
seedWatchers(dataRef.current);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
return instance;
|
|
88
|
+
});
|
|
89
|
+
/** Ensure the worker exists, spawning it on first need. */
|
|
90
|
+
const ensureWorker = useEffectEvent(() => {
|
|
91
|
+
if (!workerRef.current) workerRef.current = spawnWorker();
|
|
92
|
+
return workerRef.current;
|
|
93
|
+
});
|
|
94
|
+
/** Tear down the current worker and abandon every in-flight run. */
|
|
95
|
+
const teardown = useEffectEvent(() => {
|
|
96
|
+
workerRef.current?.terminate();
|
|
97
|
+
workerRef.current = null;
|
|
98
|
+
watchedPaths.current = [];
|
|
99
|
+
lastEval.current = [];
|
|
100
|
+
for (const [invokeId] of pending) settle(invokeId, void 0);
|
|
101
|
+
});
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
teardown();
|
|
104
|
+
if (hasScripts) ensureWorker();
|
|
105
|
+
return teardown;
|
|
106
|
+
}, [scripts, hasScripts]);
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const worker = workerRef.current;
|
|
109
|
+
if (!worker || watchedPaths.current.length === 0) return;
|
|
110
|
+
if (emitDepth.current > 0) {
|
|
111
|
+
seedWatchers(data);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
watchedPaths.current.forEach((path, watchId) => {
|
|
115
|
+
const current = getByPointer(data, path) ?? null;
|
|
116
|
+
const serialized = JSON.stringify(current);
|
|
117
|
+
const prevSerialized = lastEval.current[watchId];
|
|
118
|
+
if (serialized !== prevSerialized) {
|
|
119
|
+
lastEval.current[watchId] = serialized;
|
|
120
|
+
const previous = prevSerialized === void 0 ? void 0 : JSON.parse(prevSerialized);
|
|
121
|
+
worker.postMessage({
|
|
122
|
+
current,
|
|
123
|
+
data,
|
|
124
|
+
previous,
|
|
125
|
+
type: "runWatcher",
|
|
126
|
+
watchId
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}, [data]);
|
|
131
|
+
return useEffectEvent((handler, params) => {
|
|
132
|
+
if (!hasScripts) return Promise.resolve(void 0);
|
|
133
|
+
const worker = ensureWorker();
|
|
134
|
+
const invokeId = nextId.current++;
|
|
135
|
+
return new Promise((resolve) => {
|
|
136
|
+
const timer = setTimeout(() => {
|
|
137
|
+
settle(invokeId, void 0);
|
|
138
|
+
teardown();
|
|
139
|
+
}, INVOKE_TIMEOUT_MS);
|
|
140
|
+
pending.set(invokeId, {
|
|
141
|
+
resolve,
|
|
142
|
+
timer
|
|
143
|
+
});
|
|
144
|
+
worker.postMessage({
|
|
145
|
+
data: dataRef.current,
|
|
146
|
+
handler,
|
|
147
|
+
invokeId,
|
|
148
|
+
params: params ?? {},
|
|
149
|
+
type: "invoke"
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/** Read a JSON-pointer slice, matching the worker's getByPointer semantics. */
|
|
155
|
+
function getByPointer(data, path) {
|
|
156
|
+
if (!path || path === "/") return data;
|
|
157
|
+
try {
|
|
158
|
+
return get(data, path);
|
|
159
|
+
} catch {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
//#endregion
|
|
164
|
+
export { useProteusScripts };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
//#region src/proteus-script/workerScript.ts
|
|
2
|
+
/**
|
|
3
|
+
* Source of the sandbox Web Worker, authored as a self-contained string so it
|
|
4
|
+
* can be turned into a Blob URL and run without a separate chunk or same-origin
|
|
5
|
+
* file (mirrors how `ProteusBridge` inlines its shim script).
|
|
6
|
+
*
|
|
7
|
+
* The worker has no DOM, no `window.parent`, and communicates only via
|
|
8
|
+
* structured-clone `postMessage` — a strictly tighter sandbox than an iframe.
|
|
9
|
+
* A handler's sole capability is `ctx.emit`, which posts an existing Proteus
|
|
10
|
+
* event back to the host to be re-dispatched. See `protocol.ts` for the message
|
|
11
|
+
* shapes and `ScriptContext`.
|
|
12
|
+
*
|
|
13
|
+
* INVARIANT: script isolation depends on this worker holding no privileged
|
|
14
|
+
* globals. We `new Function(...)` the document's source below, which is only
|
|
15
|
+
* safe because there is nothing sensitive in scope. Do NOT add `importScripts`,
|
|
16
|
+
* pass host capabilities in via `postMessage`, or expose any API to handler
|
|
17
|
+
* scope — doing so turns `new Function` into an escape hatch.
|
|
18
|
+
*/
|
|
19
|
+
const WORKER_SCRIPT = `
|
|
20
|
+
"use strict";
|
|
21
|
+
|
|
22
|
+
const handlers = new Map();
|
|
23
|
+
const watchers = [];
|
|
24
|
+
const pendingEmits = new Map();
|
|
25
|
+
let nextEmitId = 1;
|
|
26
|
+
|
|
27
|
+
// Minimal JSON-pointer getter (same semantics as the host's getProteusValue).
|
|
28
|
+
function getByPointer(data, path) {
|
|
29
|
+
if (!path || path === "/") return data;
|
|
30
|
+
const parts = path.replace(/^\\//, "").split("/");
|
|
31
|
+
let current = data;
|
|
32
|
+
for (const raw of parts) {
|
|
33
|
+
if (current == null) return undefined;
|
|
34
|
+
const key = raw.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
35
|
+
current = current[key];
|
|
36
|
+
}
|
|
37
|
+
return current;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function register(name, fn) {
|
|
41
|
+
if (typeof name !== "string" || typeof fn !== "function") {
|
|
42
|
+
throw new Error("register(name, fn) requires a string name and a function");
|
|
43
|
+
}
|
|
44
|
+
handlers.set(name, fn);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function watch(path, fn) {
|
|
48
|
+
if (typeof path !== "string" || typeof fn !== "function") {
|
|
49
|
+
throw new Error("watch(path, fn) requires a string path and a function");
|
|
50
|
+
}
|
|
51
|
+
watchers.push({ fn, path });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Evaluate one module's source in a scope that exposes \`register\` and \`watch\`.
|
|
55
|
+
// Handlers are keyed by "moduleName:handlerName" via a per-module register
|
|
56
|
+
// wrapper; watchers are collected globally (order = watch id).
|
|
57
|
+
function loadModule(moduleName, source) {
|
|
58
|
+
const scopedRegister = (name, fn) => register(moduleName + ":" + name, fn);
|
|
59
|
+
// eslint-disable-next-line no-new-func
|
|
60
|
+
const factory = new Function("register", "watch", source);
|
|
61
|
+
factory(scopedRegister, watch);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function emit(event) {
|
|
65
|
+
const emitId = nextEmitId++;
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
pendingEmits.set(emitId, resolve);
|
|
68
|
+
self.postMessage({ emitId, event, type: "emit" });
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
self.onmessage = async (e) => {
|
|
73
|
+
const msg = e.data;
|
|
74
|
+
|
|
75
|
+
if (msg.type === "init") {
|
|
76
|
+
for (const [moduleName, source] of Object.entries(msg.scripts ?? {})) {
|
|
77
|
+
try {
|
|
78
|
+
loadModule(moduleName, source);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// A broken module leaves its handlers unregistered; invokes for them
|
|
81
|
+
// surface a "not found" error at call time.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Report watched paths so the host can edge-detect and drive runWatcher.
|
|
85
|
+
self.postMessage({ paths: watchers.map((w) => w.path), type: "watchers" });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (msg.type === "emitResult") {
|
|
90
|
+
const resolve = pendingEmits.get(msg.emitId);
|
|
91
|
+
if (resolve) {
|
|
92
|
+
pendingEmits.delete(msg.emitId);
|
|
93
|
+
resolve(msg.result);
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (msg.type === "invoke") {
|
|
99
|
+
const { data, handler, invokeId, params } = msg;
|
|
100
|
+
const fn = handlers.get(handler);
|
|
101
|
+
if (!fn) {
|
|
102
|
+
self.postMessage({
|
|
103
|
+
error: 'No script handler registered for "' + handler + '"',
|
|
104
|
+
invokeId,
|
|
105
|
+
result: undefined,
|
|
106
|
+
type: "invokeResult",
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const ctx = {
|
|
111
|
+
emit,
|
|
112
|
+
getValue: (path) => getByPointer(data, path),
|
|
113
|
+
};
|
|
114
|
+
try {
|
|
115
|
+
const result = await fn(ctx, params ?? {});
|
|
116
|
+
self.postMessage({ invokeId, result, type: "invokeResult" });
|
|
117
|
+
} catch (err) {
|
|
118
|
+
self.postMessage({
|
|
119
|
+
error: err && err.message ? String(err.message) : String(err),
|
|
120
|
+
invokeId,
|
|
121
|
+
result: undefined,
|
|
122
|
+
type: "invokeResult",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (msg.type === "runWatcher") {
|
|
129
|
+
const { current, data, previous, watchId } = msg;
|
|
130
|
+
const watcher = watchers[watchId];
|
|
131
|
+
if (!watcher) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const ctx = {
|
|
135
|
+
emit,
|
|
136
|
+
getValue: (path) => getByPointer(data, path),
|
|
137
|
+
};
|
|
138
|
+
try {
|
|
139
|
+
// Fire-and-forget: watchers return nothing to any caller. Errors are
|
|
140
|
+
// swallowed to match the fail-silent default of the other ops.
|
|
141
|
+
await watcher.fn(ctx, current, previous);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// Intentionally ignored.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
`;
|
|
148
|
+
//#endregion
|
|
149
|
+
export { WORKER_SCRIPT };
|
|
@@ -1210,6 +1210,32 @@ var public_schema_default = {
|
|
|
1210
1210
|
"gap": "16"
|
|
1211
1211
|
}],
|
|
1212
1212
|
"title": "Create Test Plan"
|
|
1213
|
+
},
|
|
1214
|
+
{
|
|
1215
|
+
"$type": "Document",
|
|
1216
|
+
"actions": [{
|
|
1217
|
+
"$type": "Action",
|
|
1218
|
+
"appearance": "primary",
|
|
1219
|
+
"children": "Greet",
|
|
1220
|
+
"onClick": {
|
|
1221
|
+
"params": { "name": {
|
|
1222
|
+
"$type": "Value",
|
|
1223
|
+
"path": "/name"
|
|
1224
|
+
} },
|
|
1225
|
+
"script": "main:greet"
|
|
1226
|
+
}
|
|
1227
|
+
}],
|
|
1228
|
+
"appName": "Opal",
|
|
1229
|
+
"body": [{
|
|
1230
|
+
"$type": "Field",
|
|
1231
|
+
"children": {
|
|
1232
|
+
"$type": "Input",
|
|
1233
|
+
"name": "name"
|
|
1234
|
+
},
|
|
1235
|
+
"label": "Your name"
|
|
1236
|
+
}],
|
|
1237
|
+
"scripts": { "main": "register('greet', (ctx) => ctx.emit({ message: `Hello, ${ctx.params.name}!` }))" },
|
|
1238
|
+
"title": "Scripted Greeting"
|
|
1213
1239
|
}
|
|
1214
1240
|
],
|
|
1215
1241
|
"properties": {
|
|
@@ -1252,6 +1278,11 @@ var public_schema_default = {
|
|
|
1252
1278
|
"type": "object"
|
|
1253
1279
|
},
|
|
1254
1280
|
"meta": { "description": "Additional metadata not directly consumed by Proteus. Use this to pass along any extra data." },
|
|
1281
|
+
"scripts": {
|
|
1282
|
+
"additionalProperties": { "type": "string" },
|
|
1283
|
+
"description": "Map of module name to JavaScript source. Each module runs in a sandboxed Web Worker and registers named handlers via `register(name, fn)`. Trigger a handler from any event source with `{ script: 'module:handler', params }`; handlers receive a single `ctx` ({ emit, getValue, params }) and can only affect the document by emitting the existing Proteus events through `ctx.emit`.",
|
|
1284
|
+
"type": "object"
|
|
1285
|
+
},
|
|
1255
1286
|
"subtitle": {
|
|
1256
1287
|
"$ref": "#/definitions/ProteusNode",
|
|
1257
1288
|
"description": "A brief description or tagline that provides additional context about the Proteus document's purpose."
|
|
@@ -1338,6 +1369,23 @@ var public_schema_default = {
|
|
|
1338
1369
|
"required": ["interaction"],
|
|
1339
1370
|
"type": "object"
|
|
1340
1371
|
},
|
|
1372
|
+
{
|
|
1373
|
+
"additionalProperties": false,
|
|
1374
|
+
"description": "Scripted action - runs a named handler from the document's `scripts` map in a sandboxed Web Worker. The handler can emit the other Proteus events via `ctx.emit`.",
|
|
1375
|
+
"properties": {
|
|
1376
|
+
"params": {
|
|
1377
|
+
"additionalProperties": {},
|
|
1378
|
+
"description": "Parameters passed to the handler as `ctx.params`. Values can be ProteusExpressions that resolve at call time.",
|
|
1379
|
+
"type": "object"
|
|
1380
|
+
},
|
|
1381
|
+
"script": {
|
|
1382
|
+
"description": "Handler to run, addressed as `module:handler` (the module key comes from the document's `scripts` map).",
|
|
1383
|
+
"type": "string"
|
|
1384
|
+
}
|
|
1385
|
+
},
|
|
1386
|
+
"required": ["script"],
|
|
1387
|
+
"type": "object"
|
|
1388
|
+
},
|
|
1341
1389
|
{
|
|
1342
1390
|
"additionalProperties": false,
|
|
1343
1391
|
"description": "Client-side message action",
|
|
@@ -1441,6 +1489,24 @@ var public_schema_default = {
|
|
|
1441
1489
|
},
|
|
1442
1490
|
"required": ["action", "path"],
|
|
1443
1491
|
"type": "object"
|
|
1492
|
+
},
|
|
1493
|
+
{
|
|
1494
|
+
"additionalProperties": false,
|
|
1495
|
+
"description": "Runtime data operation - writes `value` at `path` in form data, replacing any existing value (e.g. `/tags/2`). Path resolves like `Value` (absolute `/x`, relative to current parentPath, or `''` for parentPath itself).",
|
|
1496
|
+
"properties": {
|
|
1497
|
+
"action": {
|
|
1498
|
+
"const": "setValue",
|
|
1499
|
+
"description": "The action type",
|
|
1500
|
+
"type": "string"
|
|
1501
|
+
},
|
|
1502
|
+
"path": {
|
|
1503
|
+
"description": "JSON pointer path to write to",
|
|
1504
|
+
"type": "string"
|
|
1505
|
+
},
|
|
1506
|
+
"value": { "description": "The value to write; primitives or objects" }
|
|
1507
|
+
},
|
|
1508
|
+
"required": ["action", "path"],
|
|
1509
|
+
"type": "object"
|
|
1444
1510
|
}
|
|
1445
1511
|
],
|
|
1446
1512
|
"description": "Handler for user interactions - a server-side interaction call, client-side message, or client-side component action"
|
|
@@ -1197,6 +1197,32 @@ var definitions = {
|
|
|
1197
1197
|
"gap": "16"
|
|
1198
1198
|
}],
|
|
1199
1199
|
"title": "Create Test Plan"
|
|
1200
|
+
},
|
|
1201
|
+
{
|
|
1202
|
+
"$type": "Document",
|
|
1203
|
+
"actions": [{
|
|
1204
|
+
"$type": "Action",
|
|
1205
|
+
"appearance": "primary",
|
|
1206
|
+
"children": "Greet",
|
|
1207
|
+
"onClick": {
|
|
1208
|
+
"params": { "name": {
|
|
1209
|
+
"$type": "Value",
|
|
1210
|
+
"path": "/name"
|
|
1211
|
+
} },
|
|
1212
|
+
"script": "main:greet"
|
|
1213
|
+
}
|
|
1214
|
+
}],
|
|
1215
|
+
"appName": "Opal",
|
|
1216
|
+
"body": [{
|
|
1217
|
+
"$type": "Field",
|
|
1218
|
+
"children": {
|
|
1219
|
+
"$type": "Input",
|
|
1220
|
+
"name": "name"
|
|
1221
|
+
},
|
|
1222
|
+
"label": "Your name"
|
|
1223
|
+
}],
|
|
1224
|
+
"scripts": { "main": "register('greet', (ctx) => ctx.emit({ message: `Hello, ${ctx.params.name}!` }))" },
|
|
1225
|
+
"title": "Scripted Greeting"
|
|
1200
1226
|
}
|
|
1201
1227
|
],
|
|
1202
1228
|
"properties": {
|
|
@@ -1239,6 +1265,11 @@ var definitions = {
|
|
|
1239
1265
|
"type": "object"
|
|
1240
1266
|
},
|
|
1241
1267
|
"meta": { "description": "Additional metadata not directly consumed by Proteus. Use this to pass along any extra data." },
|
|
1268
|
+
"scripts": {
|
|
1269
|
+
"additionalProperties": { "type": "string" },
|
|
1270
|
+
"description": "Map of module name to JavaScript source. Each module runs in a sandboxed Web Worker and registers named handlers via `register(name, fn)`. Trigger a handler from any event source with `{ script: 'module:handler', params }`; handlers receive a single `ctx` ({ emit, getValue, params }) and can only affect the document by emitting the existing Proteus events through `ctx.emit`.",
|
|
1271
|
+
"type": "object"
|
|
1272
|
+
},
|
|
1242
1273
|
"subtitle": {
|
|
1243
1274
|
"$ref": "#/definitions/ProteusNode",
|
|
1244
1275
|
"description": "A brief description or tagline that provides additional context about the Proteus document's purpose."
|
|
@@ -1324,6 +1355,22 @@ var definitions = {
|
|
|
1324
1355
|
"required": ["interaction"],
|
|
1325
1356
|
"type": "object"
|
|
1326
1357
|
},
|
|
1358
|
+
{
|
|
1359
|
+
"description": "Scripted action - runs a named handler from the document's `scripts` map in a sandboxed Web Worker. The handler can emit the other Proteus events via `ctx.emit`.",
|
|
1360
|
+
"properties": {
|
|
1361
|
+
"params": {
|
|
1362
|
+
"additionalProperties": {},
|
|
1363
|
+
"description": "Parameters passed to the handler as `ctx.params`. Values can be ProteusExpressions that resolve at call time.",
|
|
1364
|
+
"type": "object"
|
|
1365
|
+
},
|
|
1366
|
+
"script": {
|
|
1367
|
+
"description": "Handler to run, addressed as `module:handler` (the module key comes from the document's `scripts` map).",
|
|
1368
|
+
"type": "string"
|
|
1369
|
+
}
|
|
1370
|
+
},
|
|
1371
|
+
"required": ["script"],
|
|
1372
|
+
"type": "object"
|
|
1373
|
+
},
|
|
1327
1374
|
{
|
|
1328
1375
|
"description": "Client-side message action",
|
|
1329
1376
|
"properties": { "message": {
|
|
@@ -1421,6 +1468,23 @@ var definitions = {
|
|
|
1421
1468
|
},
|
|
1422
1469
|
"required": ["action", "path"],
|
|
1423
1470
|
"type": "object"
|
|
1471
|
+
},
|
|
1472
|
+
{
|
|
1473
|
+
"description": "Runtime data operation - writes `value` at `path` in form data, replacing any existing value (e.g. `/tags/2`). Path resolves like `Value` (absolute `/x`, relative to current parentPath, or `''` for parentPath itself).",
|
|
1474
|
+
"properties": {
|
|
1475
|
+
"action": {
|
|
1476
|
+
"const": "setValue",
|
|
1477
|
+
"description": "The action type",
|
|
1478
|
+
"type": "string"
|
|
1479
|
+
},
|
|
1480
|
+
"path": {
|
|
1481
|
+
"description": "JSON pointer path to write to",
|
|
1482
|
+
"type": "string"
|
|
1483
|
+
},
|
|
1484
|
+
"value": { "description": "The value to write; primitives or objects" }
|
|
1485
|
+
},
|
|
1486
|
+
"required": ["action", "path"],
|
|
1487
|
+
"type": "object"
|
|
1424
1488
|
}
|
|
1425
1489
|
],
|
|
1426
1490
|
"description": "Handler for user interactions - a server-side interaction call, client-side message, or client-side component action"
|
package/dist/index.d.ts
CHANGED
|
@@ -432,6 +432,8 @@ type ProteusDocumentShellProps = Pick<ComponentPropsWithoutRef<typeof Disclosure
|
|
|
432
432
|
element: ProteusDocument$2;
|
|
433
433
|
/**
|
|
434
434
|
* Map of icon name to React component. Referenced by `{ $type: "Icon", name }` elements.
|
|
435
|
+
* If `name` is not in the map but starts with `http(s)://` or `data:`, it is
|
|
436
|
+
* rendered as `<img src={name}>` instead.
|
|
435
437
|
*/
|
|
436
438
|
icons?: ProteusIconMap;
|
|
437
439
|
/**
|
|
@@ -492,6 +494,7 @@ type ProteusDocument$2 = {
|
|
|
492
494
|
blocking?: boolean;
|
|
493
495
|
body: ReactNode;
|
|
494
496
|
compact?: boolean;
|
|
497
|
+
scripts?: Record<string, string>;
|
|
495
498
|
subtitle?: ReactNode;
|
|
496
499
|
title?: ReactNode;
|
|
497
500
|
titleIcon?: string;
|
|
@@ -540,11 +543,18 @@ type ProteusEventHandler = {
|
|
|
540
543
|
} | {
|
|
541
544
|
action: "removeValue";
|
|
542
545
|
path: string;
|
|
546
|
+
} | {
|
|
547
|
+
action: "setValue";
|
|
548
|
+
path: string;
|
|
549
|
+
value?: unknown;
|
|
543
550
|
} | {
|
|
544
551
|
interaction: string;
|
|
545
552
|
params?: Record<string, unknown>;
|
|
546
553
|
} | {
|
|
547
554
|
message: string | StructuredMessage;
|
|
555
|
+
} | {
|
|
556
|
+
params?: Record<string, unknown>;
|
|
557
|
+
script: string;
|
|
548
558
|
};
|
|
549
559
|
type ProteusPreviewFile = {
|
|
550
560
|
extension: string;
|
|
@@ -619,6 +629,7 @@ type ProteusDocument = {
|
|
|
619
629
|
blocking?: boolean;
|
|
620
630
|
body: unknown;
|
|
621
631
|
compact?: boolean;
|
|
632
|
+
scripts?: Record<string, string>;
|
|
622
633
|
subtitle?: unknown;
|
|
623
634
|
title?: unknown;
|
|
624
635
|
titleIcon?: string;
|
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"url": "git+https://github.com/optimizely-axiom/optiaxiom.git"
|
|
8
8
|
},
|
|
9
9
|
"type": "module",
|
|
10
|
-
"version": "3.0
|
|
10
|
+
"version": "3.1.0",
|
|
11
11
|
"files": [
|
|
12
12
|
"dist/**",
|
|
13
13
|
"LICENSE"
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"recharts": "^3.8.1",
|
|
43
43
|
"remark-gfm": "^4.0.1",
|
|
44
44
|
"@optiaxiom/icons": "^1.3.0",
|
|
45
|
-
"@optiaxiom/react": "^3.1.
|
|
45
|
+
"@optiaxiom/react": "^3.1.4"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@emotion/hash": "^0.9.2",
|