@panphora/clayjs 0.5.0 → 0.6.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/package.json +1 -1
- package/src/core/save.js +41 -0
- package/src/lib/dirty-gate.js +20 -3
- package/src/loader-logic.js +2 -1
- package/src/loader.js +2 -0
- package/src/plugins/wire.js +598 -0
- package/src/sync/live-sync.js +55 -7
package/package.json
CHANGED
package/src/core/save.js
CHANGED
|
@@ -117,6 +117,37 @@ let lastSavedContents = '';
|
|
|
117
117
|
// A save was requested while one was on the wire; run one more when it settles.
|
|
118
118
|
let pendingSave = false;
|
|
119
119
|
|
|
120
|
+
// ============================================
|
|
121
|
+
// AUTOSAVE SUSPENSION
|
|
122
|
+
// ============================================
|
|
123
|
+
//
|
|
124
|
+
// clay.wire holds this for the length of an agent request. The save is
|
|
125
|
+
// last-writer-wins with a backup, so an autosave landing while a local process is
|
|
126
|
+
// writing the same file posts the pre-agent document: the server backs the agent's
|
|
127
|
+
// bytes up and writes the browser's, the watcher's revalidation then fails, and
|
|
128
|
+
// the agent's work exists only in Backups while the page reports success.
|
|
129
|
+
//
|
|
130
|
+
// It suspends AUTOsave only. An explicit savePage — Cmd+S, a [trigger-save]
|
|
131
|
+
// button, or the wire's own pre-send flush — is a deliberate act and still runs.
|
|
132
|
+
//
|
|
133
|
+
// Reference counted, because two overlapping requests each hold it. A save
|
|
134
|
+
// skipped while suspended is replayed on release, or the user's edits would sit
|
|
135
|
+
// unsaved until something else happened to mutate the page.
|
|
136
|
+
let autosaveSuspended = 0;
|
|
137
|
+
let autosaveMissed = false;
|
|
138
|
+
|
|
139
|
+
export function suspendAutosave() {
|
|
140
|
+
autosaveSuspended++;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function resumeAutosave() {
|
|
144
|
+
if (autosaveSuspended === 0) return;
|
|
145
|
+
autosaveSuspended--;
|
|
146
|
+
if (autosaveSuspended > 0 || !autosaveMissed) return;
|
|
147
|
+
autosaveMissed = false;
|
|
148
|
+
savePageThrottled();
|
|
149
|
+
}
|
|
150
|
+
|
|
120
151
|
function skipped_(msg) {
|
|
121
152
|
return { ok: false, msg, msgType: 'skipped', code: null, etag: null };
|
|
122
153
|
}
|
|
@@ -451,6 +482,16 @@ export function savePageThrottled(callback = () => {}) {
|
|
|
451
482
|
return Promise.resolve(skipped);
|
|
452
483
|
}
|
|
453
484
|
|
|
485
|
+
// Every autosave path lands here — the mutation-driven one, the [persist] input
|
|
486
|
+
// timer, and live-sync's convergence save after a protected apply — which is
|
|
487
|
+
// why the suspension lives at this one entry rather than at each caller.
|
|
488
|
+
if (autosaveSuspended > 0) {
|
|
489
|
+
autosaveMissed = true;
|
|
490
|
+
const skipped = skipped_('Autosave suspended');
|
|
491
|
+
callback(skipped);
|
|
492
|
+
return Promise.resolve(skipped);
|
|
493
|
+
}
|
|
494
|
+
|
|
454
495
|
// For autosave: while the page is still settling, content must differ from BOTH
|
|
455
496
|
// the load-time baseline and the last save, so module setup churn cannot trigger
|
|
456
497
|
// a save. Once settled, the baseline veto is disarmed and only the last save
|
package/src/lib/dirty-gate.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import Mutation from './mutation.js';
|
|
29
29
|
import { isEditMode } from '../core/is-edit-mode.js';
|
|
30
|
+
import { STRIP_FROM_COMPARISON, SNAPSHOT_REMOVE_SELECTOR } from './region-policy.js';
|
|
30
31
|
|
|
31
32
|
let changes = 0;
|
|
32
33
|
let clearedAt = 0;
|
|
@@ -34,6 +35,22 @@ let paused = false;
|
|
|
34
35
|
let started = false;
|
|
35
36
|
|
|
36
37
|
const PERSIST_CONTROLS = 'input[persist], textarea[persist], select[persist]';
|
|
38
|
+
|
|
39
|
+
// Regions the comparison never sees. The hub feed already skips them, through
|
|
40
|
+
// `require: 'autosave'`, and the input feed has to skip them for the same
|
|
41
|
+
// reason: their content is stripped from the comparison clone, so an edit inside
|
|
42
|
+
// one can never produce a dirty root, and counting it marks the page dirty with
|
|
43
|
+
// nothing for the oracle to find — permanently, since only a save clears the
|
|
44
|
+
// counter and churn in these regions triggers none.
|
|
45
|
+
//
|
|
46
|
+
// This is not a relaxation of "never under-report". A control here is absent
|
|
47
|
+
// from the clone by definition, so there is nothing about it to under-report.
|
|
48
|
+
// It matters because a mounted tool (redpen's answer field, any panel that
|
|
49
|
+
// marks itself no-save) is a real <textarea> in the document: one keystroke in
|
|
50
|
+
// it used to freeze the live-sync save baseline for the rest of the session,
|
|
51
|
+
// after which every incoming disk change was diffed against a stale base, and
|
|
52
|
+
// the previous change was spliced back over the newer one and written to disk.
|
|
53
|
+
const GATE_IGNORE = `${STRIP_FROM_COMPARISON}, ${SNAPSHOT_REMOVE_SELECTOR}`;
|
|
37
54
|
const probeCache = new WeakMap();
|
|
38
55
|
|
|
39
56
|
function onUserInput(event) {
|
|
@@ -42,9 +59,9 @@ function onUserInput(event) {
|
|
|
42
59
|
// during a morph's async resource wait, which must keep the page dirty.
|
|
43
60
|
const el = event.target;
|
|
44
61
|
if (!el || el.nodeType !== 1) return;
|
|
45
|
-
if (el.matches('input, textarea, select') || el.isContentEditable)
|
|
46
|
-
|
|
47
|
-
|
|
62
|
+
if (!(el.matches('input, textarea, select') || el.isContentEditable)) return;
|
|
63
|
+
if (el.closest(GATE_IGNORE)) return;
|
|
64
|
+
changes++;
|
|
48
65
|
}
|
|
49
66
|
|
|
50
67
|
export function startDirtyGate() {
|
package/src/loader-logic.js
CHANGED
|
@@ -21,10 +21,11 @@ export const PLUGIN_PATHS = {
|
|
|
21
21
|
sortable: { path: "plugins/sortable.js", editOnly: true, default: false },
|
|
22
22
|
undo: { path: "plugins/undo.js", editOnly: true, default: false },
|
|
23
23
|
cms: { path: "vendor/hypercms.vendor.js", editOnly: false, default: false },
|
|
24
|
+
wire: { path: "plugins/wire.js", editOnly: false, default: false },
|
|
24
25
|
demo: { path: "plugins/demo.js", editOnly: false, default: false },
|
|
25
26
|
};
|
|
26
27
|
|
|
27
|
-
const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "cms", "sync", "demo"];
|
|
28
|
+
const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "cms", "sync", "wire", "demo"];
|
|
28
29
|
|
|
29
30
|
function parseCsv(params, key, enabled, apply) {
|
|
30
31
|
const raw = params.get(key);
|
package/src/loader.js
CHANGED
|
@@ -102,6 +102,8 @@ function attachPluginMember(path, mod) {
|
|
|
102
102
|
clay.morph = mod.morph;
|
|
103
103
|
} else if (path === "vendor/hypercms.vendor.js") {
|
|
104
104
|
clay.cms = mod.cms || mod.default;
|
|
105
|
+
} else if (path === "plugins/wire.js") {
|
|
106
|
+
clay.wire = mod.wire || mod.default;
|
|
105
107
|
} else if (path === "plugins/demo.js") {
|
|
106
108
|
clay.demo = mod.demo;
|
|
107
109
|
} else if (path === "vendor/richclay.vendor.js") {
|
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import { isEditMode } from "../core/is-edit-mode.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* clay.wire — a per-file control channel between this page and a local process.
|
|
5
|
+
*
|
|
6
|
+
* The page sends a request ("rewrite the section I circled"), a process running
|
|
7
|
+
* in the user's own terminal answers with progress and a terminal frame, and
|
|
8
|
+
* that process edits the FILE. HTML never rides the wire: the agent's change
|
|
9
|
+
* reaches this page as an ordinary external file change, through live-sync. That
|
|
10
|
+
* split is why this module has no morphing, no content lane, and no opinion
|
|
11
|
+
* about what a payload contains.
|
|
12
|
+
*
|
|
13
|
+
* Three constraints shape everything below.
|
|
14
|
+
*
|
|
15
|
+
* It must not import the sync plugin. `sync/live-sync.js` opens an EventSource
|
|
16
|
+
* on evaluation, so a static import here would give every wire page a live-sync
|
|
17
|
+
* connection it never asked for. The one thing this module needs from live-sync,
|
|
18
|
+
* "a disk frame just landed", arrives as the `clay:sync-applied` DOM event, which
|
|
19
|
+
* couples the two through the document rather than through the module graph.
|
|
20
|
+
*
|
|
21
|
+
* It runs in view mode (`editOnly: false` in the loader's plugin table), because
|
|
22
|
+
* the `/htmlfile questions` consumer and every read-only review page need it. So
|
|
23
|
+
* nothing here may assume the save lane exists: on a view-mode page `clay.save`
|
|
24
|
+
* is absent, and the save-protection below is skipped entirely rather than
|
|
25
|
+
* guarded at each call site.
|
|
26
|
+
*
|
|
27
|
+
* The stream is lazy. A browser allows six connections per origin, the pool is
|
|
28
|
+
* shared across tabs, and live-sync already holds one per tab, so a second
|
|
29
|
+
* permanent stream means three tabs of one project saturate the origin and the
|
|
30
|
+
* next save queues behind them. The wire is idle between requests by definition,
|
|
31
|
+
* so it connects on the first in-flight request and disconnects after the last
|
|
32
|
+
* one ends.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
// Every request carries a deadline, from before its POST until it ends. Nothing
|
|
36
|
+
// else can end one: the wire has no "the handler detached" signal, and a handler
|
|
37
|
+
// that acknowledged and then died would otherwise leave the request open, its
|
|
38
|
+
// promise unresolved, and saving paused for the rest of the session. That is the
|
|
39
|
+
// worst outcome available here, worse than reporting a slow agent as failed,
|
|
40
|
+
// because the page silently stops saving.
|
|
41
|
+
//
|
|
42
|
+
// It runs on two lengths. Before the first frame, the handler has taken the
|
|
43
|
+
// request (the POST already reports delivered: 0 when nobody was attached) and
|
|
44
|
+
// only has to say so.
|
|
45
|
+
const ACK_TIMEOUT_MS = 15000;
|
|
46
|
+
|
|
47
|
+
// After that, it is an inactivity deadline: every frame rearms it, and `wire
|
|
48
|
+
// serve` streams the child's stdout as status frames, so an agent that reports
|
|
49
|
+
// what it is doing keeps its request alive indefinitely. A silent one gets this
|
|
50
|
+
// long. Its work still reaches the page if it lands later, through live-sync,
|
|
51
|
+
// since the wire never carried the content anyway.
|
|
52
|
+
const SILENCE_TIMEOUT_MS = 120000;
|
|
53
|
+
|
|
54
|
+
// `wire/done` means the handler finished writing the file. It does not mean this
|
|
55
|
+
// page has rendered the change: that arrives on the live-sync lane after the
|
|
56
|
+
// watcher's quiet interval, and nothing orders the two. So a finished request
|
|
57
|
+
// waits in `landing` for the disk frame, and gives up waiting after this.
|
|
58
|
+
const LANDING_TIMEOUT_MS = 4000;
|
|
59
|
+
|
|
60
|
+
const SAVE_FLUSH_TIMEOUT_MS = 5000;
|
|
61
|
+
|
|
62
|
+
// A long review session sends many requests. Records outlive their request so a
|
|
63
|
+
// UI can still show what happened, so the map needs a ceiling; terminated ones
|
|
64
|
+
// are dropped oldest-first.
|
|
65
|
+
const MAX_RECORDS = 50;
|
|
66
|
+
|
|
67
|
+
const OPEN_STATES = new Set(["sent", "acked"]);
|
|
68
|
+
|
|
69
|
+
// Once a request has ended it stays ended. Several things can arrive after the
|
|
70
|
+
// end and each would otherwise rewrite it: a POST that was already on the wire
|
|
71
|
+
// when the user cancelled, an ack timer that fires after a fast error, a done
|
|
72
|
+
// for a request the handler also errored.
|
|
73
|
+
const TERMINAL_STATES = new Set(["done", "error", "cancelled"]);
|
|
74
|
+
|
|
75
|
+
const records = new Map();
|
|
76
|
+
const listeners = new Set();
|
|
77
|
+
|
|
78
|
+
let stream = null;
|
|
79
|
+
let streamReady = null;
|
|
80
|
+
let landingWatch = 0;
|
|
81
|
+
let landingHandler = null;
|
|
82
|
+
|
|
83
|
+
function newId() {
|
|
84
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
85
|
+
return crypto.randomUUID();
|
|
86
|
+
}
|
|
87
|
+
return `page-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Every URL is resolved against the real origin. A `<base href>` in the authored
|
|
91
|
+
// document would otherwise point this page's control channel at an origin the
|
|
92
|
+
// document chose, which is the same trap live-sync documents on its own stream.
|
|
93
|
+
function wireURL(path) {
|
|
94
|
+
return new URL(path, window.location.origin).href;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function view(rec) {
|
|
98
|
+
return {
|
|
99
|
+
id: rec.id,
|
|
100
|
+
type: rec.type,
|
|
101
|
+
state: rec.state,
|
|
102
|
+
text: rec.text,
|
|
103
|
+
error: rec.error,
|
|
104
|
+
startedAt: rec.startedAt,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function emit(rec) {
|
|
109
|
+
const snapshot = view(rec);
|
|
110
|
+
for (const fn of listeners) {
|
|
111
|
+
try {
|
|
112
|
+
fn(snapshot);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.error("clay.wire: a listener threw", err);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
document.dispatchEvent(new CustomEvent("clay:wire-state", { detail: snapshot }));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function setState(rec, state) {
|
|
121
|
+
if (rec.state === state) return;
|
|
122
|
+
rec.state = state;
|
|
123
|
+
emit(rec);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The request deadline. Armed before the POST and rearmed by every frame, so a
|
|
127
|
+
// request is bounded from end to end rather than only up to its ack.
|
|
128
|
+
function arm(rec, ms, message) {
|
|
129
|
+
clearTimeout(rec.timer);
|
|
130
|
+
rec.timer = setTimeout(() => {
|
|
131
|
+
rec.timer = null;
|
|
132
|
+
finish(rec, "error", message);
|
|
133
|
+
}, ms);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function disarm(rec) {
|
|
137
|
+
clearTimeout(rec.timer);
|
|
138
|
+
rec.timer = null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function prune() {
|
|
142
|
+
if (records.size <= MAX_RECORDS) return;
|
|
143
|
+
for (const [id, rec] of records) {
|
|
144
|
+
if (records.size <= MAX_RECORDS) break;
|
|
145
|
+
if (!OPEN_STATES.has(rec.state) && rec.state !== "landing") records.delete(id);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- the stream ------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
// A page never names a file and never asks for the handler role. Its target
|
|
152
|
+
// comes from its own URL, through the same funnel live-sync's save uses, and any
|
|
153
|
+
// file it supplied would be discarded server-side; the handler slot is refused
|
|
154
|
+
// to browsers outright, since a page holding it could receive every request on
|
|
155
|
+
// the file, including other tabs', and answer with fabricated terminal frames.
|
|
156
|
+
function openStream() {
|
|
157
|
+
if (streamReady) return streamReady;
|
|
158
|
+
|
|
159
|
+
const path = `/_/wire/subscribe?page-url=${encodeURIComponent(window.location.href)}`;
|
|
160
|
+
stream = new EventSource(wireURL(path));
|
|
161
|
+
|
|
162
|
+
stream.onmessage = (event) => {
|
|
163
|
+
let frame;
|
|
164
|
+
try {
|
|
165
|
+
frame = JSON.parse(event.data);
|
|
166
|
+
} catch {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
handleFrame(frame);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Native EventSource reconnects on its own and carries Last-Event-ID, and wire
|
|
173
|
+
// frames are stamped with one, so the server replays any terminal frame this
|
|
174
|
+
// page missed during the gap: an in-flight request recovers without help. A
|
|
175
|
+
// request cancelled during the gap is dropped by handleFrame, since its record
|
|
176
|
+
// is no longer open. What a reconnect cannot bring back is a status frame,
|
|
177
|
+
// which is display only. So there is nothing to do here.
|
|
178
|
+
stream.onerror = () => {};
|
|
179
|
+
|
|
180
|
+
streamReady = new Promise((resolve) => {
|
|
181
|
+
let settled = false;
|
|
182
|
+
const finish = () => {
|
|
183
|
+
if (settled) return;
|
|
184
|
+
settled = true;
|
|
185
|
+
resolve();
|
|
186
|
+
};
|
|
187
|
+
stream.onopen = finish;
|
|
188
|
+
// Sending is more important than subscribing: a request that never goes out
|
|
189
|
+
// because the stream would not open is worse than one whose early frames
|
|
190
|
+
// are missed.
|
|
191
|
+
setTimeout(finish, 2000);
|
|
192
|
+
});
|
|
193
|
+
return streamReady;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function closeStreamIfIdle() {
|
|
197
|
+
for (const rec of records.values()) {
|
|
198
|
+
if (OPEN_STATES.has(rec.state)) return;
|
|
199
|
+
}
|
|
200
|
+
if (!stream) return;
|
|
201
|
+
stream.close();
|
|
202
|
+
stream = null;
|
|
203
|
+
streamReady = null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function handleFrame(frame) {
|
|
207
|
+
if (!frame || typeof frame.id !== "string") return;
|
|
208
|
+
const rec = records.get(frame.id);
|
|
209
|
+
// An id this page did not issue, or one it has stopped caring about. Cancel
|
|
210
|
+
// means stop completely, so a cancelled request's late frames are dropped here
|
|
211
|
+
// rather than reopening a lifecycle the user ended.
|
|
212
|
+
if (!rec || !OPEN_STATES.has(rec.state)) return;
|
|
213
|
+
|
|
214
|
+
switch (frame.type) {
|
|
215
|
+
case "wire/ack":
|
|
216
|
+
// Rearmed, not cleared. The handler acknowledges within milliseconds of
|
|
217
|
+
// picking a request up, so clearing here would leave every working request
|
|
218
|
+
// with no deadline at all.
|
|
219
|
+
arm(rec, SILENCE_TIMEOUT_MS, "the agent stopped responding");
|
|
220
|
+
setState(rec, "acked");
|
|
221
|
+
break;
|
|
222
|
+
case "wire/status":
|
|
223
|
+
arm(rec, SILENCE_TIMEOUT_MS, "the agent stopped responding");
|
|
224
|
+
rec.text = typeof frame.text === "string" ? frame.text : "";
|
|
225
|
+
rec.state = "acked";
|
|
226
|
+
emit(rec);
|
|
227
|
+
break;
|
|
228
|
+
case "wire/done":
|
|
229
|
+
land(rec);
|
|
230
|
+
break;
|
|
231
|
+
case "wire/error":
|
|
232
|
+
finish(rec, "error", frame.text || "the handler reported an error");
|
|
233
|
+
break;
|
|
234
|
+
default:
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- the landing state -----------------------------------------------------
|
|
240
|
+
|
|
241
|
+
// `clay:sync-applied` fires on every successful remote morph, from live-sync's
|
|
242
|
+
// single apply choke point, whether or not the sync plugin was loaded by this
|
|
243
|
+
// page's own URL. Listening for it is what makes "done" mean "you can see it"
|
|
244
|
+
// instead of "the agent stopped typing", and it costs no import.
|
|
245
|
+
function watchLandings() {
|
|
246
|
+
if (landingHandler) return;
|
|
247
|
+
landingHandler = (event) => {
|
|
248
|
+
// Both apply paths dispatch this event. A peer frame is another tab's edit,
|
|
249
|
+
// so completing a landing on one would report done for bytes that are not
|
|
250
|
+
// the ones this request asked for.
|
|
251
|
+
if (event.detail?.source === "peer") return;
|
|
252
|
+
// One record per frame, oldest first (Map iteration is insertion order). A
|
|
253
|
+
// frame proves one landing and says nothing about any other request, and a
|
|
254
|
+
// request left waiting still ends on its own next frame or its timer.
|
|
255
|
+
for (const rec of records.values()) {
|
|
256
|
+
if (rec.state === "landing") {
|
|
257
|
+
finish(rec, "done", null);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
document.addEventListener("clay:sync-applied", landingHandler);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function unwatchLandingsIfIdle() {
|
|
266
|
+
if (landingWatch > 0 || !landingHandler) return;
|
|
267
|
+
document.removeEventListener("clay:sync-applied", landingHandler);
|
|
268
|
+
landingHandler = null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function land(rec) {
|
|
272
|
+
if (TERMINAL_STATES.has(rec.state) || rec.state === "landing") return;
|
|
273
|
+
disarm(rec);
|
|
274
|
+
rec.state = "landing";
|
|
275
|
+
landingWatch++;
|
|
276
|
+
watchLandings();
|
|
277
|
+
rec.landingTimer = setTimeout(() => {
|
|
278
|
+
// The change may have landed in a way this page cannot observe (no sync
|
|
279
|
+
// plugin, a frame that morphed nothing). Reporting done late is right;
|
|
280
|
+
// reporting a working agent as failed is not.
|
|
281
|
+
finish(rec, "done", null);
|
|
282
|
+
}, LANDING_TIMEOUT_MS);
|
|
283
|
+
emit(rec);
|
|
284
|
+
// The wire is done with this request even though the page is still waiting for
|
|
285
|
+
// its bytes, so the connection can go now.
|
|
286
|
+
closeStreamIfIdle();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function finish(rec, state, error) {
|
|
290
|
+
if (TERMINAL_STATES.has(rec.state)) return;
|
|
291
|
+
const wasLanding = rec.state === "landing";
|
|
292
|
+
disarm(rec);
|
|
293
|
+
clearTimeout(rec.landingTimer);
|
|
294
|
+
rec.landingTimer = null;
|
|
295
|
+
// A POST still on the wire has nothing left to report to, and an unbounded
|
|
296
|
+
// fetch is the one thing the deadline cannot otherwise reach.
|
|
297
|
+
rec.abort?.abort();
|
|
298
|
+
if (wasLanding) {
|
|
299
|
+
landingWatch--;
|
|
300
|
+
unwatchLandingsIfIdle();
|
|
301
|
+
}
|
|
302
|
+
rec.error = error;
|
|
303
|
+
rec.state = state;
|
|
304
|
+
releaseSaving(rec);
|
|
305
|
+
emit(rec);
|
|
306
|
+
closeStreamIfIdle();
|
|
307
|
+
prune();
|
|
308
|
+
if (rec.settle) {
|
|
309
|
+
const settle = rec.settle;
|
|
310
|
+
rec.settle = null;
|
|
311
|
+
settle(view(rec));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// --- save protection -------------------------------------------------------
|
|
316
|
+
//
|
|
317
|
+
// Two outbound problems the scoped live-sync merge does not touch, because it
|
|
318
|
+
// fixes the return path only.
|
|
319
|
+
//
|
|
320
|
+
// Save before send: autosave is debounced, so a request sent within that window
|
|
321
|
+
// hands the agent a file that does not contain the paragraph the user just typed
|
|
322
|
+
// and is asking about.
|
|
323
|
+
//
|
|
324
|
+
// Suspend autosave while a request is in flight: the save is last-writer-wins
|
|
325
|
+
// with a backup, not a reject. An autosave landing inside the watcher's poll and
|
|
326
|
+
// quiet window posts the pre-agent document, the server backs the agent's bytes
|
|
327
|
+
// up and still writes the browser's, the watcher's revalidation then fails, and
|
|
328
|
+
// nothing is published. The handler reports done, the page shows success, and
|
|
329
|
+
// the agent's work exists only in Backups.
|
|
330
|
+
//
|
|
331
|
+
// The lever is the save lane's own suspension, not a mutation-hub pause. Two
|
|
332
|
+
// reasons. The [persist] input autosave never goes through the hub at all — it is
|
|
333
|
+
// a raw input listener and a timer (autosave.js) — so a hub pause misses the
|
|
334
|
+
// clobber it is supposed to prevent. And pausing the hub blinds the scoped-sync
|
|
335
|
+
// dirty gate, whose one invariant is that it may over-report but must never
|
|
336
|
+
// under-report: a DOM edit the user made during the request would then read as
|
|
337
|
+
// clean, and the agent's frame would full-morph it away.
|
|
338
|
+
|
|
339
|
+
let saveDepth = 0;
|
|
340
|
+
let saveLane = null;
|
|
341
|
+
|
|
342
|
+
// Imported lazily and only in edit mode, through one shared promise. The module
|
|
343
|
+
// is not loaded on a view-mode page, so a static import from this
|
|
344
|
+
// `editOnly: false` plugin would drag the save lane into view mode to do nothing.
|
|
345
|
+
let saveLaneReady = null;
|
|
346
|
+
function loadSaveLane() {
|
|
347
|
+
if (!saveLaneReady) saveLaneReady = import("../core/save.js");
|
|
348
|
+
return saveLaneReady;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function holdSaving(rec) {
|
|
352
|
+
if (!isEditMode || rec.holdsSave) return;
|
|
353
|
+
// Resolve the module BEFORE claiming the hold. Claiming it first and awaiting
|
|
354
|
+
// afterwards leaves a window where a cancel releases a hold that was never
|
|
355
|
+
// taken, and the import that resumes after it suspends a lane nothing will
|
|
356
|
+
// ever resume.
|
|
357
|
+
const lane = await loadSaveLane();
|
|
358
|
+
if (rec.state !== "sent") return;
|
|
359
|
+
saveLane = lane;
|
|
360
|
+
rec.holdsSave = true;
|
|
361
|
+
if (saveDepth++ > 0) return;
|
|
362
|
+
lane.suspendAutosave();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function releaseSaving(rec) {
|
|
366
|
+
if (!rec.holdsSave) return;
|
|
367
|
+
rec.holdsSave = false;
|
|
368
|
+
if (--saveDepth > 0) return;
|
|
369
|
+
saveDepth = 0;
|
|
370
|
+
saveLane?.resumeAutosave();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function once(eventName, timeout) {
|
|
374
|
+
return new Promise((resolve) => {
|
|
375
|
+
let done = false;
|
|
376
|
+
const settle = () => {
|
|
377
|
+
if (done) return;
|
|
378
|
+
done = true;
|
|
379
|
+
document.removeEventListener(eventName, settle);
|
|
380
|
+
resolve();
|
|
381
|
+
};
|
|
382
|
+
document.addEventListener(eventName, settle);
|
|
383
|
+
setTimeout(settle, timeout);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Put what the user is looking at on disk, or fail the request.
|
|
389
|
+
*
|
|
390
|
+
* The wire's promise is that the agent reads the document the user is asking
|
|
391
|
+
* about. A flush that could not deliver that has to end the request: posting
|
|
392
|
+
* anyway means an agent editing a file without the paragraph it was asked about,
|
|
393
|
+
* silently, which is worse than an error the UI can show.
|
|
394
|
+
*/
|
|
395
|
+
async function flushSave() {
|
|
396
|
+
if (!isEditMode) return;
|
|
397
|
+
const clay = window.clay;
|
|
398
|
+
if (!clay || typeof clay.save !== "function") return;
|
|
399
|
+
|
|
400
|
+
// isSaveInProgress comes from the module, not from clay.internals: that
|
|
401
|
+
// surface is an opt-in satellite script, absent on every page the loader
|
|
402
|
+
// builds, so reading it here made "is a save on the wire" permanently false.
|
|
403
|
+
const { isSaveInProgress } = await import("../core/save-core.js");
|
|
404
|
+
const deadline = Date.now() + SAVE_FLUSH_TIMEOUT_MS;
|
|
405
|
+
|
|
406
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
407
|
+
if (isSaveInProgress()) {
|
|
408
|
+
await once("clay:save-saved", Math.max(0, deadline - Date.now()));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const result = await clay.save();
|
|
412
|
+
// `ok` is the outcome and msgType is the server's severity, so a save that
|
|
413
|
+
// landed with a warning is a success. `skipped` covers two outcomes: nothing
|
|
414
|
+
// to save, and a save already on the wire. Only the second means these bytes
|
|
415
|
+
// never left, and asking whether one is still in progress tells them apart.
|
|
416
|
+
if (result?.ok) return;
|
|
417
|
+
if (result?.msgType === "skipped" && !isSaveInProgress()) return;
|
|
418
|
+
if (result?.msgType === "error" || result?.msgType === "unknown") {
|
|
419
|
+
throw new Error(`could not save this page first: ${result.msg || result.msgType}`);
|
|
420
|
+
}
|
|
421
|
+
if (Date.now() >= deadline) break;
|
|
422
|
+
}
|
|
423
|
+
throw new Error("could not save this page before sending");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// --- sending ---------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
async function postFrame(body, signal) {
|
|
429
|
+
const response = await fetch(wireURL("/_/wire/send"), {
|
|
430
|
+
method: "POST",
|
|
431
|
+
headers: {
|
|
432
|
+
"Content-Type": "application/json",
|
|
433
|
+
"Page-URL": window.location.href,
|
|
434
|
+
},
|
|
435
|
+
body: JSON.stringify(body),
|
|
436
|
+
signal,
|
|
437
|
+
});
|
|
438
|
+
let reply = null;
|
|
439
|
+
try {
|
|
440
|
+
reply = await response.json();
|
|
441
|
+
} catch {
|
|
442
|
+
reply = null;
|
|
443
|
+
}
|
|
444
|
+
return { ok: response.ok, status: response.status, reply };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Send one request and track it to a terminal state.
|
|
449
|
+
*
|
|
450
|
+
* Returns a handle immediately; `handle.done` resolves with the final snapshot
|
|
451
|
+
* and never rejects, because a failed request is an outcome the UI renders
|
|
452
|
+
* rather than an exception it catches.
|
|
453
|
+
*/
|
|
454
|
+
function send(payload, opts = {}) {
|
|
455
|
+
const id = typeof opts.id === "string" && opts.id ? opts.id : newId();
|
|
456
|
+
const type = typeof opts.type === "string" && opts.type ? opts.type : "wire/request";
|
|
457
|
+
|
|
458
|
+
// The id is request identity on the wire, on both sides: a reused one would
|
|
459
|
+
// route the first request's frames to the second record, leaving the first
|
|
460
|
+
// unresolvable and its save hold never released. Refused as an outcome rather
|
|
461
|
+
// than thrown, so a UI renders it the way it renders any other failure.
|
|
462
|
+
if (records.has(id)) {
|
|
463
|
+
const clash = {
|
|
464
|
+
id,
|
|
465
|
+
type,
|
|
466
|
+
state: "error",
|
|
467
|
+
text: "",
|
|
468
|
+
error: "a request with this id is already on the wire",
|
|
469
|
+
startedAt: Date.now(),
|
|
470
|
+
};
|
|
471
|
+
return {
|
|
472
|
+
id,
|
|
473
|
+
get state() {
|
|
474
|
+
return clash.state;
|
|
475
|
+
},
|
|
476
|
+
done: Promise.resolve(view(clash)),
|
|
477
|
+
cancel: () => false,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const rec = {
|
|
482
|
+
id,
|
|
483
|
+
type,
|
|
484
|
+
state: "sent",
|
|
485
|
+
text: "",
|
|
486
|
+
error: null,
|
|
487
|
+
startedAt: Date.now(),
|
|
488
|
+
timer: null,
|
|
489
|
+
landingTimer: null,
|
|
490
|
+
abort: typeof AbortController === "function" ? new AbortController() : null,
|
|
491
|
+
holdsSave: false,
|
|
492
|
+
settle: null,
|
|
493
|
+
};
|
|
494
|
+
rec.done = new Promise((resolve) => {
|
|
495
|
+
rec.settle = resolve;
|
|
496
|
+
});
|
|
497
|
+
records.set(id, rec);
|
|
498
|
+
emit(rec);
|
|
499
|
+
|
|
500
|
+
const dispatch = async () => {
|
|
501
|
+
// The record is re-checked after every await. A cancel can land in any of
|
|
502
|
+
// these gaps, and a step that ran on regardless would open a stream nobody
|
|
503
|
+
// closes or post a request the user already took back.
|
|
504
|
+
await holdSaving(rec);
|
|
505
|
+
if (rec.state !== "sent") return;
|
|
506
|
+
|
|
507
|
+
await flushSave();
|
|
508
|
+
if (rec.state !== "sent") return;
|
|
509
|
+
|
|
510
|
+
// Subscribe before posting. A handler can acknowledge in single-digit
|
|
511
|
+
// milliseconds, a fresh subscription deliberately replays nothing, and only
|
|
512
|
+
// terminal frames are retained at all, so posting first is how a page ends
|
|
513
|
+
// up watching a request whose ack it already missed.
|
|
514
|
+
await openStream();
|
|
515
|
+
if (rec.state !== "sent") {
|
|
516
|
+
closeStreamIfIdle(); // the stream this cancelled request just opened
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Armed BEFORE the POST, not after it. A stalled fetch would otherwise leave
|
|
521
|
+
// the request unbounded, and an ack that arrives while the POST is still in
|
|
522
|
+
// flight — the ordinary case, since the page subscribed first — would be
|
|
523
|
+
// followed by a fresh ack timer that fails a healthy request 15s later.
|
|
524
|
+
arm(rec, ACK_TIMEOUT_MS, "the agent never answered");
|
|
525
|
+
|
|
526
|
+
const { ok, status, reply } = await postFrame(
|
|
527
|
+
{ type: rec.type, id: rec.id, text: opts.text, payload },
|
|
528
|
+
rec.abort?.signal
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
// A frame may have moved this request on while its own POST was in flight.
|
|
532
|
+
// Only a request still waiting on that POST may be failed by it.
|
|
533
|
+
if (rec.state !== "sent") return;
|
|
534
|
+
|
|
535
|
+
if (!ok) {
|
|
536
|
+
finish(rec, "error", `the wire refused this request (${status})`);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (!reply || reply.delivered === 0) {
|
|
540
|
+
// Accepted by the router and taken by nobody. Reporting this as an error
|
|
541
|
+
// rather than a pending request is the difference between a UI that says
|
|
542
|
+
// "start an agent" and one that spins forever.
|
|
543
|
+
finish(rec, "error", "no agent is attached to this file");
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
dispatch().catch((err) => {
|
|
548
|
+
finish(rec, "error", err && err.message ? err.message : String(err));
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
return {
|
|
552
|
+
id,
|
|
553
|
+
get state() {
|
|
554
|
+
return rec.state;
|
|
555
|
+
},
|
|
556
|
+
done: rec.done,
|
|
557
|
+
cancel: () => cancel(id),
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Stop completely: the request ends here, its late frames are ignored, and the
|
|
563
|
+
* handler is asked to stop. A cancel that only hid the spinner would leave the
|
|
564
|
+
* agent writing the file the user just took back.
|
|
565
|
+
*/
|
|
566
|
+
function cancel(id) {
|
|
567
|
+
const rec = records.get(id);
|
|
568
|
+
if (!rec || !OPEN_STATES.has(rec.state)) return false;
|
|
569
|
+
postFrame({ type: "wire/cancel", id }).catch(() => {});
|
|
570
|
+
finish(rec, "cancelled", null);
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function get(id) {
|
|
575
|
+
const rec = records.get(id);
|
|
576
|
+
return rec ? view(rec) : undefined;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function list() {
|
|
580
|
+
return [...records.values()].map(view);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function isBusy() {
|
|
584
|
+
for (const rec of records.values()) {
|
|
585
|
+
if (OPEN_STATES.has(rec.state)) return true;
|
|
586
|
+
}
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function on(fn) {
|
|
591
|
+
if (typeof fn !== "function") return () => {};
|
|
592
|
+
listeners.add(fn);
|
|
593
|
+
return () => listeners.delete(fn);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export const wire = { send, cancel, get, list, isBusy, on };
|
|
597
|
+
|
|
598
|
+
export default wire;
|
package/src/sync/live-sync.js
CHANGED
|
@@ -423,6 +423,34 @@ class LiveSync {
|
|
|
423
423
|
if (this.onConnect) this.onConnect();
|
|
424
424
|
};
|
|
425
425
|
|
|
426
|
+
// The cursor frame is a NAMED SSE event, so it never reaches onmessage and
|
|
427
|
+
// never looks like data. Its `resync` flag says the server could not retain
|
|
428
|
+
// everything between where this client resumed and the baseline it is
|
|
429
|
+
// sending: what this page holds is stale in a way no replay will fix.
|
|
430
|
+
//
|
|
431
|
+
// The repair is the token-free fetch of the served document this class
|
|
432
|
+
// already runs for a change too large to send, so noticing the flag is the
|
|
433
|
+
// whole of the work.
|
|
434
|
+
this.sse.addEventListener('cursor', (event) => {
|
|
435
|
+
let data;
|
|
436
|
+
try {
|
|
437
|
+
data = JSON.parse(event.data);
|
|
438
|
+
} catch {
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (!data || data.resync !== true) return;
|
|
442
|
+
console.log('[LiveSync] Server could not replay everything; refetching the document');
|
|
443
|
+
// _fetchServedDocument, deliberately, and not _fetchExternalChange: that
|
|
444
|
+
// one drops a fetch whose seq is at or below the external watermark, and
|
|
445
|
+
// the cursor baseline routinely is, since it is the server's high-water
|
|
446
|
+
// mark and our own last applied change may already have reached it. A
|
|
447
|
+
// resync that skipped itself for being "already seen" would leave the page
|
|
448
|
+
// permanently stale, which is the exact failure the flag exists to report.
|
|
449
|
+
this._fetchServedDocument(typeof data.seq === 'number' ? data.seq : undefined, {
|
|
450
|
+
repair: true,
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
426
454
|
this.sse.onmessage = (event) => {
|
|
427
455
|
const data = JSON.parse(event.data);
|
|
428
456
|
|
|
@@ -654,20 +682,29 @@ class LiveSync {
|
|
|
654
682
|
* frame the epoch check refused (the fetched body is whatever disk holds
|
|
655
683
|
* NOW, which is always safe to apply).
|
|
656
684
|
*/
|
|
657
|
-
_fetchServedDocument(seq, attempt = 0) {
|
|
685
|
+
_fetchServedDocument(seq, { attempt = 0, repair = false } = {}) {
|
|
658
686
|
const epoch = this._saveEpoch;
|
|
659
687
|
fetch(new URL(window.location.href), { cache: 'no-store' })
|
|
660
688
|
.then((response) => (response.ok ? response.text() : null))
|
|
661
689
|
.then((html) => {
|
|
662
690
|
if (this.isDestroyed || html == null) return;
|
|
663
|
-
if (typeof seq === 'number' && seq < this._lastExternalSeq)
|
|
691
|
+
if (typeof seq === 'number' && seq < this._lastExternalSeq) {
|
|
692
|
+
// A newer external change superseded this one, and its own fetch will
|
|
693
|
+
// queue a body. Except for a repair: that one exists because the server
|
|
694
|
+
// said replay cannot fix this page, so if the newer fetch fails there is
|
|
695
|
+
// nothing else coming. Refetch rather than drop the only repair.
|
|
696
|
+
if (repair && attempt < 3) {
|
|
697
|
+
this._fetchServedDocument(seq, { attempt: attempt + 1, repair });
|
|
698
|
+
}
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
664
701
|
if (this._saveEpoch > epoch) {
|
|
665
702
|
// An own save landed while the GET was in flight, so this body may
|
|
666
703
|
// predate it. Save-response order proves nothing about disk-write
|
|
667
704
|
// order — refetch for the newest bytes instead of dropping.
|
|
668
705
|
if (attempt < 3) {
|
|
669
706
|
console.log('[LiveSync] Refetching external change: own save landed mid-fetch');
|
|
670
|
-
this._fetchServedDocument(seq, attempt + 1);
|
|
707
|
+
this._fetchServedDocument(seq, { attempt: attempt + 1, repair });
|
|
671
708
|
}
|
|
672
709
|
return;
|
|
673
710
|
}
|
|
@@ -989,8 +1026,12 @@ class LiveSync {
|
|
|
989
1026
|
// onmessage before applyUpdate is ever called. Covers every SSE morph
|
|
990
1027
|
// source (peer edit, version restore, body-swap) since they all funnel
|
|
991
1028
|
// through this single choke point.
|
|
1029
|
+
//
|
|
1030
|
+
// `source` is what lets a listener tell the two apply paths apart. clay.wire
|
|
1031
|
+
// waits for a DISK frame to call an agent's write landed, and another tab's
|
|
1032
|
+
// edit arriving first would otherwise report the wrong bytes as delivered.
|
|
992
1033
|
document.dispatchEvent(new CustomEvent('clay:sync-applied', {
|
|
993
|
-
detail: { seq }
|
|
1034
|
+
detail: { seq, source: 'peer' }
|
|
994
1035
|
}));
|
|
995
1036
|
} finally {
|
|
996
1037
|
this._log('applyUpdate - morph complete, resuming mutations');
|
|
@@ -1042,7 +1083,14 @@ class LiveSync {
|
|
|
1042
1083
|
const parser = new DOMParser();
|
|
1043
1084
|
const newDoc = parser.parseFromString(html, 'text/html');
|
|
1044
1085
|
|
|
1045
|
-
|
|
1086
|
+
// Lane-guarded exactly like the peer path. A view-mode tab has no save
|
|
1087
|
+
// baseline — every writer of lastSavedContents is edit-gated — so
|
|
1088
|
+
// protectDiskDoc can only ever refuse, and the frame would hold, retry
|
|
1089
|
+
// every 3s, and hold again forever. The gate still reads dirty there,
|
|
1090
|
+
// because persistProbeDirty inspects the live DOM and a visitor can type
|
|
1091
|
+
// into a [persist] field. Before the resync repair this path was
|
|
1092
|
+
// unreachable outside the live lane; now it is the repair's own route.
|
|
1093
|
+
if (this.lane === 'live' && pageMaybeDirty()) {
|
|
1046
1094
|
const protection = protectDiskDoc({ newDoc });
|
|
1047
1095
|
if (!protection.ok) {
|
|
1048
1096
|
// Hold: nothing morphs, no baseline moves, and deliberately NO
|
|
@@ -1094,7 +1142,7 @@ class LiveSync {
|
|
|
1094
1142
|
|
|
1095
1143
|
window.scrollTo(scrollX, scrollY);
|
|
1096
1144
|
|
|
1097
|
-
if (retainedRoots === 0 && !pageMaybeDirty()) {
|
|
1145
|
+
if (this.lane === 'live' && retainedRoots === 0 && !pageMaybeDirty()) {
|
|
1098
1146
|
// Clean apply: the DOM now IS the disk state, so a local comparison
|
|
1099
1147
|
// capture of it is the truthful baseline. The next no-op save skips,
|
|
1100
1148
|
// beforeunload stays quiet. The dirty re-check matters: typing that
|
|
@@ -1115,7 +1163,7 @@ class LiveSync {
|
|
|
1115
1163
|
}
|
|
1116
1164
|
|
|
1117
1165
|
document.dispatchEvent(new CustomEvent('clay:sync-applied', {
|
|
1118
|
-
detail: { seq }
|
|
1166
|
+
detail: { seq, source: 'disk' }
|
|
1119
1167
|
}));
|
|
1120
1168
|
} finally {
|
|
1121
1169
|
this._log('applyExternal - morph complete, resuming mutations');
|