@panphora/clayjs 0.4.3 → 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/admin-contenteditable.js +4 -3
- package/src/core/admin-inputs.js +5 -3
- package/src/core/admin-onclick.js +4 -3
- package/src/core/admin-resources.js +8 -4
- package/src/core/is-edit-mode.js +3 -2
- package/src/core/save.js +57 -3
- package/src/core/snapshot.js +59 -4
- package/src/lib/dirty-gate.js +169 -0
- package/src/lib/root-attrs.js +5 -1
- 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 +489 -39
- package/src/sync/splice-merge.js +259 -0
- package/src/vendor/hyper-morph.vendor.js +4 -2
- package/src/vendor/hypercms.vendor.js +14 -14
|
@@ -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;
|