@erdemtuna/doc-review 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -222
- package/{src → lib}/SKILL.md +5 -2
- package/lib/anchor-text.js +155 -0
- package/lib/atomic-write.js +54 -0
- package/lib/changes-controller.js +184 -0
- package/lib/chrome-api.js +78 -0
- package/lib/chrome-client.js +1700 -0
- package/lib/chrome-session.js +73 -0
- package/lib/chrome.html +53 -0
- package/lib/cli.js +255 -0
- package/lib/click-target.js +28 -0
- package/lib/comment-anchor.js +23 -0
- package/lib/comment-target.js +164 -0
- package/lib/comments-controller.js +109 -0
- package/lib/contextual-controller.js +33 -0
- package/lib/contracts/feedback.js +1 -0
- package/lib/contracts/frame.js +7 -0
- package/lib/contracts/history.js +1 -0
- package/lib/contracts/index.js +2 -0
- package/lib/contracts/page.js +23 -0
- package/lib/controller-store.js +96 -0
- package/lib/document-execution.js +115 -0
- package/lib/edit-limits.js +24 -0
- package/lib/editing.js +107 -0
- package/lib/execution-client.js +19 -0
- package/lib/feedback-controller.js +113 -0
- package/lib/feedback-panel-controller.js +161 -0
- package/lib/frame-channel.js +42 -0
- package/lib/frame-controller.js +313 -0
- package/lib/frame-host.js +136 -0
- package/lib/frame-policy.js +50 -0
- package/lib/history-client.js +112 -0
- package/lib/history-coordinator.js +217 -0
- package/lib/history-policy.js +43 -0
- package/lib/history-server.js +464 -0
- package/lib/html-transform.js +52 -0
- package/lib/icons.js +325 -0
- package/{src → lib}/markdown.js +25 -26
- package/lib/paths.js +83 -0
- package/lib/poll-transport.js +220 -0
- package/lib/positioning.js +97 -0
- package/lib/recovery-controller.js +123 -0
- package/lib/review-controller.js +93 -0
- package/lib/review-mode.js +66 -0
- package/lib/revision-diff.js +482 -0
- package/lib/revision-schema.js +235 -0
- package/lib/revision-store.js +190 -0
- package/lib/save-controller.js +303 -0
- package/lib/sdk.js +2629 -0
- package/lib/semantic-snapshot.js +268 -0
- package/lib/serialize.js +32 -0
- package/lib/server-entry.js +26 -0
- package/lib/server-lock.js +109 -0
- package/lib/server.js +1501 -0
- package/lib/setup-guidance.js +119 -0
- package/{src → lib}/setup.js +44 -55
- package/lib/state.js +733 -0
- package/lib/toolbar-controller.js +32 -0
- package/lib/ui/THIRD_PARTY_NOTICES.md +1254 -0
- package/lib/ui/chrome.css +4881 -0
- package/lib/ui/chrome.js +52 -0
- package/lib/view-identity.js +112 -0
- package/package.json +50 -14
- package/src/anchor-text.js +0 -160
- package/src/atomic-write.js +0 -53
- package/src/chrome-client.js +0 -2747
- package/src/chrome-session.js +0 -85
- package/src/chrome.css +0 -652
- package/src/chrome.html +0 -196
- package/src/cli.js +0 -271
- package/src/click-target.js +0 -26
- package/src/comment-anchor.js +0 -22
- package/src/comment-target.js +0 -164
- package/src/comparison-view.js +0 -337
- package/src/document-execution.js +0 -103
- package/src/edit-limits.js +0 -23
- package/src/editing.js +0 -99
- package/src/execution-client.js +0 -63
- package/src/frame-channel.js +0 -44
- package/src/frame-policy.js +0 -54
- package/src/history-client.js +0 -104
- package/src/history-coordinator.js +0 -186
- package/src/history-policy.js +0 -43
- package/src/history-server.js +0 -463
- package/src/html-transform.js +0 -62
- package/src/icons.js +0 -251
- package/src/paths.js +0 -91
- package/src/poll-transport.js +0 -222
- package/src/positioning.js +0 -107
- package/src/review-mode.js +0 -59
- package/src/revision-diff.js +0 -440
- package/src/revision-schema.js +0 -219
- package/src/revision-store.js +0 -177
- package/src/sdk.js +0 -2607
- package/src/semantic-snapshot.js +0 -230
- package/src/serialize.js +0 -32
- package/src/server-entry.js +0 -26
- package/src/server-lock.js +0 -101
- package/src/server.js +0 -1466
- package/src/setup-guidance.js +0 -128
- package/src/state.js +0 -732
- package/src/view-identity.js +0 -99
- /package/{src → lib}/document-trust.js +0 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { createControllerStore } from "./controller-store.js";
|
|
2
|
+
export const createNoteDraft = () => ({ text: "", selectionStart: 0, selectionEnd: 0, composing: false });
|
|
3
|
+
function savePresentation(state) {
|
|
4
|
+
if (state.save.conflict)
|
|
5
|
+
return "Source changed — reload latest before saving. Your page edits remain in this tab.";
|
|
6
|
+
if (state.kind === "url")
|
|
7
|
+
return "Localhost page — your direct edits go to the agent for source updates";
|
|
8
|
+
if (state.markdown)
|
|
9
|
+
return "Markdown source — edits go to the agent as feedback";
|
|
10
|
+
if (state.save.dynamic)
|
|
11
|
+
return "Live page — edits go to the agent, the file is left alone";
|
|
12
|
+
if (state.save.status === "saving")
|
|
13
|
+
return `Saving to ${state.filename}…`;
|
|
14
|
+
if (state.save.status === "failed")
|
|
15
|
+
return "Couldn't save — retry before sending";
|
|
16
|
+
return `Saved to ${state.filename}${state.save.savedAt ? ` · ${state.save.savedAt}` : ""}`;
|
|
17
|
+
}
|
|
18
|
+
export function createFeedbackPanelController(options) {
|
|
19
|
+
let disposed = false;
|
|
20
|
+
let expanded = false;
|
|
21
|
+
let editsOpen = true;
|
|
22
|
+
let dialog = null;
|
|
23
|
+
let copyStatus = "idle";
|
|
24
|
+
let copyError = "";
|
|
25
|
+
const fingerprint = (action, state) => JSON.stringify(action === "revert"
|
|
26
|
+
? state.edits : [state.total, state.drafts, state.note.text]);
|
|
27
|
+
const valid = (owned, full = true) => {
|
|
28
|
+
const state = options.read();
|
|
29
|
+
return !disposed && !state.ended && state.available && !state.delivery.sending &&
|
|
30
|
+
state.identity === owned.identity && (!full ||
|
|
31
|
+
(state.source === owned.source && fingerprint(owned.action, state) === owned.fingerprint));
|
|
32
|
+
};
|
|
33
|
+
const store = createControllerStore(() => {
|
|
34
|
+
const state = options.read();
|
|
35
|
+
const busy = state.delivery.sending;
|
|
36
|
+
const sent = state.delivery.sent || state.agent === "working" || state.agent === "stranded";
|
|
37
|
+
const count = state.total;
|
|
38
|
+
const hasNote = !!state.note.text.trim();
|
|
39
|
+
return {
|
|
40
|
+
note: state.note, ended: state.ended, disabled: state.ended || !state.available,
|
|
41
|
+
edits: expanded ? state.edits : state.edits.slice(0, 5), editCount: state.edits.length, expanded, editsOpen,
|
|
42
|
+
saveText: savePresentation(state), saveError: state.save.conflict || state.save.status === "failed",
|
|
43
|
+
saveStatus: state.save.status, drafts: state.drafts, busy, delivery: state.delivery,
|
|
44
|
+
sendDisabled: state.ended || !state.available || (!count && !hasNote) || busy || sent || !!dialog || state.note.composing,
|
|
45
|
+
sendText: busy ? "Sending feedback…" : state.agent === "working" ? "Feedback delivered"
|
|
46
|
+
: state.agent === "stranded" ? "Sent — agent is not listening"
|
|
47
|
+
: state.delivery.sent ? "Sent — waiting for agent" : count ? `Send ${count} to agent`
|
|
48
|
+
: hasNote ? "Send note to agent" : "Nothing to send yet",
|
|
49
|
+
agent: state.agent, prompt: state.prompt, copyStatus, copyError,
|
|
50
|
+
dialog: dialog ? { ...dialog, stale: !valid(dialog) } : null,
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
const publish = () => { if (!disposed)
|
|
54
|
+
store.publish(); };
|
|
55
|
+
const available = () => !disposed && !options.read().ended && options.read().available;
|
|
56
|
+
async function send() {
|
|
57
|
+
const state = options.read();
|
|
58
|
+
if (!available() || dialog || state.note.composing || (!state.total && !state.note.text.trim()) ||
|
|
59
|
+
state.delivery.sending || state.delivery.sent || state.agent === "working" || state.agent === "stranded")
|
|
60
|
+
return;
|
|
61
|
+
await options.send();
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
getSnapshot: store.getSnapshot, subscribe: store.subscribe, publish,
|
|
65
|
+
commands: {
|
|
66
|
+
setEditsOpen(open) {
|
|
67
|
+
if (!available() || dialog)
|
|
68
|
+
return;
|
|
69
|
+
editsOpen = open;
|
|
70
|
+
publish();
|
|
71
|
+
},
|
|
72
|
+
updateNote(value) {
|
|
73
|
+
if (!available() || dialog)
|
|
74
|
+
return;
|
|
75
|
+
Object.assign(options.read().note, value);
|
|
76
|
+
publish();
|
|
77
|
+
},
|
|
78
|
+
expand() { if (available()) {
|
|
79
|
+
expanded = !expanded;
|
|
80
|
+
publish();
|
|
81
|
+
} },
|
|
82
|
+
send,
|
|
83
|
+
open(action) {
|
|
84
|
+
const state = options.read();
|
|
85
|
+
if (!available() || dialog || state.delivery.sending || (action === "revert" && !state.edits.length))
|
|
86
|
+
return;
|
|
87
|
+
if (action === "revert")
|
|
88
|
+
editsOpen = true;
|
|
89
|
+
const drafts = state.drafts + Number(!!state.note.text.trim());
|
|
90
|
+
dialog = {
|
|
91
|
+
action, identity: state.identity, source: state.source, fingerprint: fingerprint(action, state),
|
|
92
|
+
pending: false, error: "",
|
|
93
|
+
description: action === "revert" ? `Discard all ${state.edits.length} of your edits? Comments and the overall note are kept.`
|
|
94
|
+
: `${state.total ? `${state.total} unsent ${state.total === 1 ? "item will" : "items will"} be kept for next time. ` : ""}${drafts ? `${drafts} open ${drafts === 1 ? "draft (including any overall note) is" : "drafts (including any overall note) are"} only in this tab and will not be sent. ` : ""}The waiting agent will be told to stop polling.`,
|
|
95
|
+
};
|
|
96
|
+
publish();
|
|
97
|
+
},
|
|
98
|
+
cancel() { if (dialog && !dialog.pending) {
|
|
99
|
+
dialog = null;
|
|
100
|
+
publish();
|
|
101
|
+
} },
|
|
102
|
+
async confirm() {
|
|
103
|
+
const owned = dialog;
|
|
104
|
+
if (!owned || owned.pending)
|
|
105
|
+
return;
|
|
106
|
+
if (!valid(owned)) {
|
|
107
|
+
owned.error = "The page or feedback changed. Cancel and review the current state before trying again.";
|
|
108
|
+
publish();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
owned.pending = true;
|
|
112
|
+
owned.error = "";
|
|
113
|
+
publish();
|
|
114
|
+
try {
|
|
115
|
+
// Flush the SDK debounce window before either destructive request.
|
|
116
|
+
await options.flush(owned.action);
|
|
117
|
+
if (!valid(owned, false))
|
|
118
|
+
throw new Error("The page changed while preparing this action. Cancel and try again on the current page.");
|
|
119
|
+
if (owned.action === "revert")
|
|
120
|
+
await options.revert();
|
|
121
|
+
else
|
|
122
|
+
await options.end();
|
|
123
|
+
if (!disposed && dialog === owned)
|
|
124
|
+
dialog = null;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (!disposed && dialog === owned) {
|
|
128
|
+
owned.error = error instanceof Error ? error.message : String(error);
|
|
129
|
+
options.failed(owned.error);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
owned.pending = false;
|
|
134
|
+
publish();
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
async copy() {
|
|
138
|
+
const prompt = options.read().prompt;
|
|
139
|
+
if (!available() || !prompt || copyStatus === "copying")
|
|
140
|
+
return;
|
|
141
|
+
copyStatus = "copying";
|
|
142
|
+
copyError = "";
|
|
143
|
+
publish();
|
|
144
|
+
try {
|
|
145
|
+
await options.copy(prompt);
|
|
146
|
+
if (!disposed)
|
|
147
|
+
copyStatus = "copied";
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
if (!disposed) {
|
|
151
|
+
copyStatus = "failed";
|
|
152
|
+
copyError = "Couldn't copy — select the prompt and copy it manually";
|
|
153
|
+
options.failed(copyError);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
publish();
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
dispose() { disposed = true; dialog = null; store.dispose(); },
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
let channel = null;
|
|
2
|
+
export function initializeChannel(capability, generation, pageKey) {
|
|
3
|
+
if (channel)
|
|
4
|
+
throw new Error("Doc Review frame channel is already initialized.");
|
|
5
|
+
channel = {
|
|
6
|
+
capability: String(capability),
|
|
7
|
+
generation: Number(generation),
|
|
8
|
+
pageKey: String(pageKey),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export function initializeChannelFromDocument() {
|
|
12
|
+
const script = document.querySelector("script[data-eh-sdk][data-eh-bootstrap]");
|
|
13
|
+
if (!script)
|
|
14
|
+
throw new Error("Doc Review frame bootstrap is missing.");
|
|
15
|
+
const capability = script.nonce;
|
|
16
|
+
const generation = Number(script.dataset.generation);
|
|
17
|
+
const pageKey = String(script.dataset.pageKey || "");
|
|
18
|
+
script.remove();
|
|
19
|
+
if (!capability || !Number.isSafeInteger(generation) || !pageKey) {
|
|
20
|
+
throw new Error("Doc Review frame bootstrap is invalid.");
|
|
21
|
+
}
|
|
22
|
+
initializeChannel(capability, generation, pageKey);
|
|
23
|
+
}
|
|
24
|
+
export function frameMessage(type, payload = {}) {
|
|
25
|
+
if (!channel)
|
|
26
|
+
throw new Error("Doc Review frame channel is not initialized.");
|
|
27
|
+
return {
|
|
28
|
+
...payload,
|
|
29
|
+
type,
|
|
30
|
+
capability: channel.capability,
|
|
31
|
+
generation: channel.generation,
|
|
32
|
+
pageKey: channel.pageKey,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function matchesFrameMessage(message) {
|
|
36
|
+
return !!(channel &&
|
|
37
|
+
message && (typeof message === "object" || typeof message === "function") &&
|
|
38
|
+
"capability" in message && "generation" in message && "pageKey" in message &&
|
|
39
|
+
message.capability === channel.capability &&
|
|
40
|
+
message.generation === channel.generation &&
|
|
41
|
+
message.pageKey === channel.pageKey);
|
|
42
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { record } from "./chrome-api.js";
|
|
2
|
+
export function createFrameController({ sessionId, host, request, suspended, transitioning = () => { }, failed, now = Date.now, setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, }) {
|
|
3
|
+
const state = {
|
|
4
|
+
key: null, generation: 0, renderId: null, capability: null, sourceHash: null,
|
|
5
|
+
execution: null, policy: null, phase: { kind: "loading" }, readyAt: 0,
|
|
6
|
+
configurationGeneration: null, pendingReload: false, reloading: false,
|
|
7
|
+
};
|
|
8
|
+
const listeners = new Set();
|
|
9
|
+
const timers = new Set();
|
|
10
|
+
const flushes = new Map();
|
|
11
|
+
let readinessTimer = null;
|
|
12
|
+
let replacementTimer = null;
|
|
13
|
+
let readinessRetries = 0;
|
|
14
|
+
let initialLoad = false;
|
|
15
|
+
let readyGeneration = null;
|
|
16
|
+
let sourceRevision = 0;
|
|
17
|
+
let flushSequence = 0;
|
|
18
|
+
let configuration = null;
|
|
19
|
+
let expectedConfiguration = null;
|
|
20
|
+
const alive = () => state.phase.kind !== "disposed";
|
|
21
|
+
const loading = () => state.phase.kind !== "ready";
|
|
22
|
+
const publish = () => { if (alive())
|
|
23
|
+
for (const listener of listeners)
|
|
24
|
+
listener(); };
|
|
25
|
+
const identity = () => ({
|
|
26
|
+
key: state.key, renderId: state.renderId, generation: state.generation, loading: loading(),
|
|
27
|
+
});
|
|
28
|
+
const matches = (value) => alive() && value.key === state.key &&
|
|
29
|
+
value.generation === state.generation && value.renderId === state.renderId;
|
|
30
|
+
const cancelTimer = (timer) => {
|
|
31
|
+
if (timer !== null) {
|
|
32
|
+
clearTimer(timer);
|
|
33
|
+
timers.delete(timer);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
function later(callback, delay) {
|
|
37
|
+
const timer = setTimer(() => {
|
|
38
|
+
timers.delete(timer);
|
|
39
|
+
if (alive())
|
|
40
|
+
callback();
|
|
41
|
+
}, delay);
|
|
42
|
+
timers.add(timer);
|
|
43
|
+
return timer;
|
|
44
|
+
}
|
|
45
|
+
function send(message) {
|
|
46
|
+
if (!alive() || loading() || !state.capability)
|
|
47
|
+
return;
|
|
48
|
+
host.send({
|
|
49
|
+
...message, capability: state.capability, generation: state.generation, pageKey: state.key,
|
|
50
|
+
}, state.policy?.targetOrigin || "*");
|
|
51
|
+
}
|
|
52
|
+
function suspend() {
|
|
53
|
+
if (!alive())
|
|
54
|
+
return;
|
|
55
|
+
suspended();
|
|
56
|
+
for (const flush of flushes.values())
|
|
57
|
+
flush.settle(false);
|
|
58
|
+
configuration?.settle(false);
|
|
59
|
+
expectedConfiguration = null;
|
|
60
|
+
for (const timer of timers)
|
|
61
|
+
clearTimer(timer);
|
|
62
|
+
timers.clear();
|
|
63
|
+
readinessTimer = replacementTimer = null;
|
|
64
|
+
state.capability = null;
|
|
65
|
+
state.renderId = null;
|
|
66
|
+
state.execution = null;
|
|
67
|
+
state.phase = { kind: "suspended" };
|
|
68
|
+
readyGeneration = null;
|
|
69
|
+
state.configurationGeneration = null;
|
|
70
|
+
host.suspend();
|
|
71
|
+
}
|
|
72
|
+
function begin(key = state.key) {
|
|
73
|
+
if (!alive())
|
|
74
|
+
throw new Error("Review ended");
|
|
75
|
+
transitioning();
|
|
76
|
+
suspend();
|
|
77
|
+
state.key = key;
|
|
78
|
+
state.generation++;
|
|
79
|
+
state.sourceHash = null;
|
|
80
|
+
sourceRevision++;
|
|
81
|
+
state.readyAt = 0;
|
|
82
|
+
state.phase = { kind: "loading" };
|
|
83
|
+
return state.generation;
|
|
84
|
+
}
|
|
85
|
+
function finishReplacement() {
|
|
86
|
+
cancelTimer(replacementTimer);
|
|
87
|
+
replacementTimer = null;
|
|
88
|
+
host.finishReplacement();
|
|
89
|
+
publish();
|
|
90
|
+
}
|
|
91
|
+
function fail(message) {
|
|
92
|
+
if (!alive())
|
|
93
|
+
return;
|
|
94
|
+
suspend();
|
|
95
|
+
state.phase = { kind: "failed", message };
|
|
96
|
+
state.pendingReload = true;
|
|
97
|
+
finishReplacement();
|
|
98
|
+
failed(message);
|
|
99
|
+
}
|
|
100
|
+
async function register(key, generation) {
|
|
101
|
+
if (!alive() || !key)
|
|
102
|
+
throw new Error("No active review page is selected.");
|
|
103
|
+
const value = record(await request(`/api/session/${sessionId}/render`, {
|
|
104
|
+
method: "POST", body: JSON.stringify({ key, generation }),
|
|
105
|
+
}));
|
|
106
|
+
if (!alive() || state.key !== key || state.generation !== generation)
|
|
107
|
+
return false;
|
|
108
|
+
if (typeof value.renderId !== "string" || typeof value.capability !== "string" ||
|
|
109
|
+
typeof value.path !== "string" || !value.path.startsWith("/artifact/") ||
|
|
110
|
+
(value.sourceHash != null && typeof value.sourceHash !== "string")) {
|
|
111
|
+
throw new Error("The review server returned an invalid frame registration.");
|
|
112
|
+
}
|
|
113
|
+
state.renderId = value.renderId;
|
|
114
|
+
state.capability = value.capability;
|
|
115
|
+
state.sourceHash = typeof value.sourceHash === "string" ? value.sourceHash : null;
|
|
116
|
+
sourceRevision++;
|
|
117
|
+
state.phase = { kind: "loading" };
|
|
118
|
+
initialLoad = true;
|
|
119
|
+
host.navigate(value.path, state.reloading);
|
|
120
|
+
publish();
|
|
121
|
+
readinessTimer = later(() => {
|
|
122
|
+
if (state.key !== key || state.generation !== generation || !loading())
|
|
123
|
+
return;
|
|
124
|
+
if (readinessRetries < 1) {
|
|
125
|
+
readinessRetries++;
|
|
126
|
+
rotate();
|
|
127
|
+
}
|
|
128
|
+
else
|
|
129
|
+
fail("The reviewed page did not finish loading. Fix the source or local server, then reload.");
|
|
130
|
+
}, 5000);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
function rotate() {
|
|
134
|
+
if (!state.key || !alive())
|
|
135
|
+
return;
|
|
136
|
+
const key = state.key;
|
|
137
|
+
const generation = begin();
|
|
138
|
+
void register(key, generation).catch((error) => {
|
|
139
|
+
if (state.key === key && state.generation === generation) {
|
|
140
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const stopLoad = host.onLoad(() => {
|
|
145
|
+
if (!state.renderId || !state.capability)
|
|
146
|
+
return;
|
|
147
|
+
if (initialLoad) {
|
|
148
|
+
initialLoad = false;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
rotate();
|
|
152
|
+
});
|
|
153
|
+
async function ready() {
|
|
154
|
+
if (!alive() || !state.renderId || !state.capability || readyGeneration === state.generation)
|
|
155
|
+
return null;
|
|
156
|
+
cancelTimer(readinessTimer);
|
|
157
|
+
readinessTimer = null;
|
|
158
|
+
readinessRetries = 0;
|
|
159
|
+
readyGeneration = state.generation;
|
|
160
|
+
state.readyAt = now();
|
|
161
|
+
state.phase = { kind: "confirming" };
|
|
162
|
+
const started = identity();
|
|
163
|
+
const revision = sourceRevision;
|
|
164
|
+
try {
|
|
165
|
+
const value = record(await request(`/api/session/${sessionId}/render/${state.renderId}/ready`, {
|
|
166
|
+
method: "POST",
|
|
167
|
+
body: JSON.stringify({ capability: state.capability, generation: state.generation, pageKey: state.key }),
|
|
168
|
+
}));
|
|
169
|
+
if (!matches(started))
|
|
170
|
+
return null;
|
|
171
|
+
if ((value.executionMode !== "static" && value.executionMode !== "interactive" && value.executionMode !== "application") ||
|
|
172
|
+
(value.savePolicy !== "writable" && value.savePolicy !== "feedback-only") ||
|
|
173
|
+
(value.sourceHash != null && typeof value.sourceHash !== "string")) {
|
|
174
|
+
throw new Error("The review could not confirm this document's execution policy. Reload before editing.");
|
|
175
|
+
}
|
|
176
|
+
if (revision === sourceRevision) {
|
|
177
|
+
state.sourceHash = typeof value.sourceHash === "string" ? value.sourceHash : null;
|
|
178
|
+
}
|
|
179
|
+
state.execution = {
|
|
180
|
+
executionMode: value.executionMode, savePolicy: value.savePolicy,
|
|
181
|
+
feedbackOnly: value.savePolicy === "feedback-only",
|
|
182
|
+
executionNotice: typeof value.executionNotice === "string" ? value.executionNotice : null,
|
|
183
|
+
};
|
|
184
|
+
state.phase = { kind: "ready" };
|
|
185
|
+
host.ready(state.execution);
|
|
186
|
+
publish();
|
|
187
|
+
return state.execution;
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (matches(started)) {
|
|
191
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
192
|
+
const message = "The review could not confirm this document's execution policy. Reload before editing.";
|
|
193
|
+
fail(detail.startsWith(message) ? detail : `${message} ${detail}`);
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function configurationTimeout() {
|
|
199
|
+
if (!host.previous)
|
|
200
|
+
return;
|
|
201
|
+
cancelTimer(replacementTimer);
|
|
202
|
+
const started = identity();
|
|
203
|
+
replacementTimer = later(() => {
|
|
204
|
+
if (matches(started) && host.previous) {
|
|
205
|
+
fail("The page did not confirm its review settings. Reload before continuing.");
|
|
206
|
+
}
|
|
207
|
+
}, 5000);
|
|
208
|
+
}
|
|
209
|
+
function configure(mode, savePolicy, wait = true) {
|
|
210
|
+
if (loading() || !state.capability || !state.execution)
|
|
211
|
+
return Promise.resolve(false);
|
|
212
|
+
configuration?.settle(false);
|
|
213
|
+
expectedConfiguration = { mode, savePolicy };
|
|
214
|
+
configurationTimeout();
|
|
215
|
+
if (!wait) {
|
|
216
|
+
send({ type: "eh:configureReview", mode, savePolicy });
|
|
217
|
+
return Promise.resolve(true);
|
|
218
|
+
}
|
|
219
|
+
return new Promise((resolve) => {
|
|
220
|
+
const timer = later(() => settle(false), 3000);
|
|
221
|
+
const settle = (applied) => {
|
|
222
|
+
if (configuration?.settle !== settle)
|
|
223
|
+
return;
|
|
224
|
+
configuration = null;
|
|
225
|
+
cancelTimer(timer);
|
|
226
|
+
resolve(applied);
|
|
227
|
+
};
|
|
228
|
+
configuration = { mode, savePolicy, settle };
|
|
229
|
+
send({ type: "eh:configureReview", mode, savePolicy });
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
function flush(strict = false) {
|
|
233
|
+
if (loading() || !state.capability) {
|
|
234
|
+
return strict ? Promise.reject(new Error("The page is not ready. Wait for it to load, then retry.")) : Promise.resolve();
|
|
235
|
+
}
|
|
236
|
+
return new Promise((resolve, reject) => {
|
|
237
|
+
const requestId = `flush-${++flushSequence}`;
|
|
238
|
+
const timer = later(() => settle(false), strict ? 6000 : 400);
|
|
239
|
+
const settle = (confirmed) => {
|
|
240
|
+
if (!flushes.delete(requestId))
|
|
241
|
+
return;
|
|
242
|
+
cancelTimer(timer);
|
|
243
|
+
if (!confirmed && strict)
|
|
244
|
+
reject(new Error("The page did not finish saving. Wait for it to load, then retry."));
|
|
245
|
+
else
|
|
246
|
+
resolve();
|
|
247
|
+
};
|
|
248
|
+
flushes.set(requestId, { strict, settle });
|
|
249
|
+
send({ type: "eh:flush", requestId });
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
get state() { return state; },
|
|
254
|
+
get loading() { return loading(); },
|
|
255
|
+
identity, matches, begin, register, suspend, ready, configure, flush, send, fail,
|
|
256
|
+
subscribe(listener) {
|
|
257
|
+
listeners.add(listener);
|
|
258
|
+
return () => { listeners.delete(listener); };
|
|
259
|
+
},
|
|
260
|
+
seedGeneration(generation) {
|
|
261
|
+
if (Number.isSafeInteger(generation) && state.renderId === null)
|
|
262
|
+
state.generation = generation;
|
|
263
|
+
},
|
|
264
|
+
resetRetries() { readinessRetries = 0; },
|
|
265
|
+
setPolicy(policy) { state.policy = policy; host.setPolicy(policy); },
|
|
266
|
+
setSourceHash(hash) { state.sourceHash = hash; sourceRevision++; },
|
|
267
|
+
startReload() { state.reloading = true; state.pendingReload = false; },
|
|
268
|
+
restoredScroll() { state.reloading = false; },
|
|
269
|
+
holdReload() { state.pendingReload = true; publish(); },
|
|
270
|
+
accepts(event) {
|
|
271
|
+
if (!alive() || !host.acceptsSource(event.source) || !state.policy ||
|
|
272
|
+
event.origin !== state.policy.incomingOrigin || !state.capability)
|
|
273
|
+
return false;
|
|
274
|
+
const value = event.data;
|
|
275
|
+
if (!value || typeof value !== "object" || !("capability" in value) ||
|
|
276
|
+
!("generation" in value) || !("pageKey" in value) || !("type" in value))
|
|
277
|
+
return false;
|
|
278
|
+
return value.capability === state.capability && value.generation === state.generation &&
|
|
279
|
+
value.pageKey === state.key && typeof value.type === "string" &&
|
|
280
|
+
(!loading() || value.type === "eh:ready");
|
|
281
|
+
},
|
|
282
|
+
configured(mode, savePolicy) {
|
|
283
|
+
if (!expectedConfiguration || expectedConfiguration.mode !== mode ||
|
|
284
|
+
expectedConfiguration.savePolicy !== savePolicy || loading())
|
|
285
|
+
return false;
|
|
286
|
+
state.configurationGeneration = state.generation;
|
|
287
|
+
if (host.previous)
|
|
288
|
+
host.afterPaint(() => { if (!loading())
|
|
289
|
+
finishReplacement(); });
|
|
290
|
+
if (configuration && configuration.mode === mode && configuration.savePolicy === savePolicy) {
|
|
291
|
+
configuration.settle(true);
|
|
292
|
+
}
|
|
293
|
+
return true;
|
|
294
|
+
},
|
|
295
|
+
flushed(requestId) {
|
|
296
|
+
if (typeof requestId === "string")
|
|
297
|
+
flushes.get(requestId)?.settle(true);
|
|
298
|
+
else
|
|
299
|
+
for (const request of flushes.values())
|
|
300
|
+
if (!request.strict)
|
|
301
|
+
request.settle(true);
|
|
302
|
+
},
|
|
303
|
+
dispose() {
|
|
304
|
+
if (!alive())
|
|
305
|
+
return;
|
|
306
|
+
suspend();
|
|
307
|
+
state.phase = { kind: "disposed" };
|
|
308
|
+
stopLoad();
|
|
309
|
+
host.dispose();
|
|
310
|
+
listeners.clear();
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
export function createFrameHost(initial, artifactOrigin) {
|
|
2
|
+
let current = initial;
|
|
3
|
+
let previous = null;
|
|
4
|
+
let disposed = false;
|
|
5
|
+
const loadListeners = new Set();
|
|
6
|
+
const focusListeners = new Set();
|
|
7
|
+
const detach = new Map();
|
|
8
|
+
const paintFrames = new Set();
|
|
9
|
+
function paint(callback) {
|
|
10
|
+
const id = requestAnimationFrame(() => {
|
|
11
|
+
paintFrames.delete(id);
|
|
12
|
+
if (!disposed)
|
|
13
|
+
callback();
|
|
14
|
+
});
|
|
15
|
+
paintFrames.add(id);
|
|
16
|
+
}
|
|
17
|
+
function attach(frame) {
|
|
18
|
+
const loaded = () => {
|
|
19
|
+
if (!disposed && frame === current)
|
|
20
|
+
for (const listener of loadListeners)
|
|
21
|
+
listener();
|
|
22
|
+
};
|
|
23
|
+
const focused = () => {
|
|
24
|
+
if (!disposed && frame === current)
|
|
25
|
+
for (const listener of focusListeners)
|
|
26
|
+
listener();
|
|
27
|
+
};
|
|
28
|
+
frame.addEventListener("load", loaded);
|
|
29
|
+
frame.addEventListener("focus", focused);
|
|
30
|
+
detach.set(frame, () => {
|
|
31
|
+
frame.removeEventListener("load", loaded);
|
|
32
|
+
frame.removeEventListener("focus", focused);
|
|
33
|
+
detach.delete(frame);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function remove(frame) {
|
|
37
|
+
detach.get(frame)?.();
|
|
38
|
+
frame.remove();
|
|
39
|
+
}
|
|
40
|
+
attach(initial);
|
|
41
|
+
return {
|
|
42
|
+
get current() { return current; },
|
|
43
|
+
get previous() { return previous; },
|
|
44
|
+
get visibleExecution() {
|
|
45
|
+
const visible = previous || current;
|
|
46
|
+
const savePolicy = visible.dataset.savePolicy;
|
|
47
|
+
const executionMode = visible.dataset.executionMode;
|
|
48
|
+
if ((savePolicy !== "writable" && savePolicy !== "feedback-only") ||
|
|
49
|
+
(executionMode !== "static" && executionMode !== "interactive" && executionMode !== "application"))
|
|
50
|
+
return null;
|
|
51
|
+
return {
|
|
52
|
+
executionMode,
|
|
53
|
+
savePolicy,
|
|
54
|
+
executionNotice: visible.dataset.executionNotice || null,
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
onLoad(listener) {
|
|
58
|
+
loadListeners.add(listener);
|
|
59
|
+
return () => { loadListeners.delete(listener); };
|
|
60
|
+
},
|
|
61
|
+
onFocus(listener) {
|
|
62
|
+
focusListeners.add(listener);
|
|
63
|
+
return () => { focusListeners.delete(listener); };
|
|
64
|
+
},
|
|
65
|
+
setPolicy(policy) { current.setAttribute("sandbox", policy.sandbox); },
|
|
66
|
+
navigate(path, replacing) {
|
|
67
|
+
if (disposed)
|
|
68
|
+
return;
|
|
69
|
+
const url = `${artifactOrigin}${path}`;
|
|
70
|
+
if (replacing && current.hasAttribute("src")) {
|
|
71
|
+
const next = current.cloneNode(false);
|
|
72
|
+
const Frame = current.ownerDocument.defaultView?.HTMLIFrameElement;
|
|
73
|
+
if (!Frame || !(next instanceof Frame))
|
|
74
|
+
throw new Error("Invalid review iframe.");
|
|
75
|
+
next.src = url;
|
|
76
|
+
next.removeAttribute("data-sdk-ready");
|
|
77
|
+
delete next.dataset.executionMode;
|
|
78
|
+
delete next.dataset.savePolicy;
|
|
79
|
+
delete next.dataset.executionNotice;
|
|
80
|
+
if (previous)
|
|
81
|
+
remove(current);
|
|
82
|
+
else {
|
|
83
|
+
previous = current;
|
|
84
|
+
previous.id = "previousFrame";
|
|
85
|
+
previous.inert = true;
|
|
86
|
+
previous.setAttribute("aria-hidden", "true");
|
|
87
|
+
previous.tabIndex = -1;
|
|
88
|
+
}
|
|
89
|
+
current = next;
|
|
90
|
+
current.dataset.replacing = "true";
|
|
91
|
+
attach(current);
|
|
92
|
+
previous.after(current);
|
|
93
|
+
}
|
|
94
|
+
if (current.src !== url)
|
|
95
|
+
current.src = url;
|
|
96
|
+
},
|
|
97
|
+
finishReplacement() {
|
|
98
|
+
if (previous)
|
|
99
|
+
remove(previous);
|
|
100
|
+
previous = null;
|
|
101
|
+
delete current.dataset.replacing;
|
|
102
|
+
},
|
|
103
|
+
suspend() { current.removeAttribute("data-sdk-ready"); },
|
|
104
|
+
ready(execution) {
|
|
105
|
+
current.dataset.sdkReady = "true";
|
|
106
|
+
current.dataset.executionMode = execution.executionMode;
|
|
107
|
+
current.dataset.savePolicy = execution.savePolicy;
|
|
108
|
+
current.dataset.executionNotice = execution.executionNotice || "";
|
|
109
|
+
},
|
|
110
|
+
send(message, origin) {
|
|
111
|
+
current.contentWindow?.postMessage(message, origin);
|
|
112
|
+
},
|
|
113
|
+
acceptsSource(source) {
|
|
114
|
+
return !!current.contentWindow && source === current.contentWindow;
|
|
115
|
+
},
|
|
116
|
+
afterPaint(callback) {
|
|
117
|
+
const target = current;
|
|
118
|
+
paint(() => paint(() => {
|
|
119
|
+
if (!disposed && target === current)
|
|
120
|
+
callback();
|
|
121
|
+
}));
|
|
122
|
+
},
|
|
123
|
+
dispose() {
|
|
124
|
+
if (disposed)
|
|
125
|
+
return;
|
|
126
|
+
disposed = true;
|
|
127
|
+
for (const stop of [...detach.values()])
|
|
128
|
+
stop();
|
|
129
|
+
for (const id of paintFrames)
|
|
130
|
+
cancelAnimationFrame(id);
|
|
131
|
+
paintFrames.clear();
|
|
132
|
+
loadListeners.clear();
|
|
133
|
+
focusListeners.clear();
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|