@panphora/clayjs 1.6.1 → 1.8.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 +5 -4
- package/dist/clay.standalone.js +9578 -6063
- package/entries/clay-data.js +8 -6
- package/package.json +2 -1
- package/src/core/conflict-notice.js +2 -4
- package/src/core/etag.js +67 -3
- package/src/core/host-attrs.js +14 -0
- package/src/core/page-data.js +321 -0
- package/src/core/save.js +32 -16
- package/src/lib/content-dom.js +3 -3
- package/src/lib/root-attrs.js +14 -0
- package/src/loader-logic.js +2 -0
- package/src/loader.js +7 -2
- package/src/plugins/ai-edit.js +307 -89
- package/src/plugins/wire.js +4 -4
- package/src/sync/conflict-footprints.js +278 -0
- package/src/sync/conflict-revert.js +18 -5
- package/src/sync/conflicts.js +30 -1
- package/src/sync/live-sync.js +331 -38
- package/src/sync/stream.js +17 -4
- package/src/ui/bevel-controls.js +8 -2
- package/src/ui/bevel-dialog.js +2 -4
- package/src/ui/bevel.js +22 -2
- package/src/ui/dialogs.js +1 -1
- package/src/ui/skins/cms.js +1 -1
- package/src/ui/skins/quickcrop.js +1 -1
- package/src/ui/skins/richclay.js +2 -2
- package/src/ui/toast.js +7 -15
- package/src/vendor/hyper-morph.vendor.js +4 -4
- package/src/vendor/hypercms.vendor.js +12 -12
- package/src/vendor/quickcrop.vendor.js +1 -1
- package/src/vendor/richclay.vendor.js +27 -19
package/src/sync/live-sync.js
CHANGED
|
@@ -55,11 +55,13 @@ import { presence } from './presence.js';
|
|
|
55
55
|
// `clay:sync-applied`, which this file is the only dispatcher of.
|
|
56
56
|
import './section-notice.js';
|
|
57
57
|
import { hostMeta } from '../core/host-meta.js';
|
|
58
|
-
import {
|
|
58
|
+
import { servedDocumentEtag } from '../core/host-attrs.js';
|
|
59
|
+
import { recordEtag, seedEtag, lastSeenEtag, conditionalSaves, representedEtag, forgetRepresentedEtag } from '../core/etag.js';
|
|
59
60
|
import { pageMaybeDirty, pauseGate, resumeGate, gateCaptureToken, gateClearIfUnchanged } from '../lib/dirty-gate.js';
|
|
60
61
|
import { gestureSeen } from '../lib/user-gesture.js';
|
|
61
62
|
import { SyncStream } from './stream.js';
|
|
62
63
|
import { conflicts, beginApply, completeApply, failApply } from './conflicts.js';
|
|
64
|
+
import { beginLineageCapture } from './conflict-footprints.js';
|
|
63
65
|
|
|
64
66
|
// What a live-sync merge never reads or touches on any side: editor chrome,
|
|
65
67
|
// content kept out of the save or the snapshot, frozen regions, and nodes
|
|
@@ -121,6 +123,23 @@ const uniqueAuthoredIdentity = (memo) => (el) => {
|
|
|
121
123
|
|
|
122
124
|
const isIdMap = (m) => !!m && typeof m === 'object' && !Array.isArray(m);
|
|
123
125
|
|
|
126
|
+
/**
|
|
127
|
+
* The version a fetched served document names on its own root, or null.
|
|
128
|
+
*
|
|
129
|
+
* The host injects this attribute into the response it builds from one read of
|
|
130
|
+
* the file, so a served-document GET answers which version the body it just
|
|
131
|
+
* returned actually is. Parsed detached: this reads one attribute and never
|
|
132
|
+
* touches the live page, and a body that cannot be parsed simply offers no stamp.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} html
|
|
135
|
+
* @returns {?string}
|
|
136
|
+
*/
|
|
137
|
+
function readServedStamp(html) {
|
|
138
|
+
if (typeof html !== 'string' || html === '') return null;
|
|
139
|
+
const parsed = new DOMParser().parseFromString(html, 'text/html');
|
|
140
|
+
return parsed.documentElement?.getAttribute('documentetag') || null;
|
|
141
|
+
}
|
|
142
|
+
|
|
124
143
|
// The page just took a frame verified clean against its baseline, so it now IS
|
|
125
144
|
// the file on disk. Both saved baselines move together from one capture: leaving
|
|
126
145
|
// the dirty baseline behind would make the close warning fire on the frame's own
|
|
@@ -324,6 +343,16 @@ class LiveSync {
|
|
|
324
343
|
this._holdRetryPeer = null;
|
|
325
344
|
this._holdRetryExt = null;
|
|
326
345
|
|
|
346
|
+
// The startup check: the one fetch this class runs against the page it was
|
|
347
|
+
// itself served, started once by the transport's first cursor and never
|
|
348
|
+
// again. `_servedFetchId` numbers every served-document GET, so a response
|
|
349
|
+
// that arrives after a newer GET was issued can tell it is superseded — only
|
|
350
|
+
// the startup check acts on that, since a repair is re-issued rather than
|
|
351
|
+
// re-read. `_startupRetry` is the single pending re-ask, coalesced so two
|
|
352
|
+
// retries cannot pile up behind one another.
|
|
353
|
+
this._servedFetchId = 0;
|
|
354
|
+
this._startupRetry = null;
|
|
355
|
+
|
|
327
356
|
// Whether each lane is currently holding. A hold is a safe outcome but a
|
|
328
357
|
// silent one: without an event the page simply stops updating and nothing
|
|
329
358
|
// can say why. These make the transitions observable in both directions.
|
|
@@ -586,6 +615,12 @@ class LiveSync {
|
|
|
586
615
|
clearTimeout(this._holdRetryExt);
|
|
587
616
|
this._holdRetryPeer = null;
|
|
588
617
|
this._holdRetryExt = null;
|
|
618
|
+
|
|
619
|
+
// A stopped run's startup check must not act on what it finds: the next
|
|
620
|
+
// fetch, if any, owns the question.
|
|
621
|
+
clearTimeout(this._startupRetry);
|
|
622
|
+
this._startupRetry = null;
|
|
623
|
+
this._servedFetchId++;
|
|
589
624
|
}
|
|
590
625
|
|
|
591
626
|
/**
|
|
@@ -717,6 +752,10 @@ class LiveSync {
|
|
|
717
752
|
shared: this._sharedSync,
|
|
718
753
|
documentURL: window.location.href,
|
|
719
754
|
lane: this.lane,
|
|
755
|
+
// Only a response that named its own version gives this tab anything to
|
|
756
|
+
// compare against. On a host that served none there is no provenance to
|
|
757
|
+
// check, and the page keeps its pre-1.9.0 startup behavior.
|
|
758
|
+
startupCheck: Boolean(servedDocumentEtag),
|
|
720
759
|
});
|
|
721
760
|
|
|
722
761
|
this.sse.onopen = () => {
|
|
@@ -739,8 +778,22 @@ class LiveSync {
|
|
|
739
778
|
} catch {
|
|
740
779
|
return;
|
|
741
780
|
}
|
|
742
|
-
|
|
743
|
-
|
|
781
|
+
// Two different reasons for one refetch. `resync` is the server saying it
|
|
782
|
+
// could not replay everything between where this client resumed and the
|
|
783
|
+
// baseline it is sending. `startup` is this tab's own check of the page
|
|
784
|
+
// it was served: the file may have changed between that response and this
|
|
785
|
+
// first subscription, and a stamped response is the only thing that can
|
|
786
|
+
// tell. One fetch covers both: a body that names its own version is
|
|
787
|
+
// compared whatever asked for it, and the flag only decides what an
|
|
788
|
+
// unstamped answer may do.
|
|
789
|
+
if (!data || (data.resync !== true && data.startup !== true)) return;
|
|
790
|
+
const repair = data.resync === true;
|
|
791
|
+
const startup = data.startup === true && !repair;
|
|
792
|
+
console.log(
|
|
793
|
+
repair
|
|
794
|
+
? '[LiveSync] Server could not replay everything; refetching the document'
|
|
795
|
+
: '[LiveSync] Checking the served document against the version this tab was served'
|
|
796
|
+
);
|
|
744
797
|
// _fetchServedDocument, deliberately, and not _fetchExternalChange: that
|
|
745
798
|
// one drops a fetch whose seq is at or below the external watermark, and
|
|
746
799
|
// the cursor baseline routinely is, since it is the server's high-water
|
|
@@ -748,7 +801,8 @@ class LiveSync {
|
|
|
748
801
|
// resync that skipped itself for being "already seen" would leave the page
|
|
749
802
|
// permanently stale, which is the exact failure the flag exists to report.
|
|
750
803
|
this._fetchServedDocument(typeof data.seq === 'number' ? data.seq : undefined, {
|
|
751
|
-
repair
|
|
804
|
+
repair,
|
|
805
|
+
startup,
|
|
752
806
|
});
|
|
753
807
|
});
|
|
754
808
|
|
|
@@ -1099,52 +1153,183 @@ class LiveSync {
|
|
|
1099
1153
|
* check of its own — callers own that — so it can also re-materialize a
|
|
1100
1154
|
* frame the epoch check refused (the fetched body is whatever disk holds
|
|
1101
1155
|
* NOW, which is always safe to apply).
|
|
1156
|
+
*
|
|
1157
|
+
* `startup` marks the one fetch that asks whether the page this tab was served
|
|
1158
|
+
* is still what disk holds. It differs from every other reason to fetch in that
|
|
1159
|
+
* an unchanged answer must do NOTHING: no queue, no apply, no baseline move, no
|
|
1160
|
+
* event, no stamp change. A no-op is not a missed frame here, it is the answer.
|
|
1161
|
+
* A body that names its own version is judged the same way, and a repair that
|
|
1162
|
+
* carries one is no exception.
|
|
1163
|
+
*
|
|
1164
|
+
* @param {number} [seq]
|
|
1165
|
+
* @param {Object} [options]
|
|
1166
|
+
* @param {number} [options.attempt] - Bounded retry count, carried through
|
|
1167
|
+
* every re-ask so a page under constant churn stops asking.
|
|
1168
|
+
* @param {boolean} [options.repair] - The server could not replay everything.
|
|
1169
|
+
* @param {boolean} [options.startup] - The served page's version check.
|
|
1102
1170
|
*/
|
|
1103
|
-
_fetchServedDocument(seq, { attempt = 0, repair = false } = {}) {
|
|
1171
|
+
_fetchServedDocument(seq, { attempt = 0, repair = false, startup = false } = {}) {
|
|
1172
|
+
// Content already on its way in is about to change what this check would be
|
|
1173
|
+
// comparing, and its version question is newer than this one. Let it land and
|
|
1174
|
+
// ask again; a check that judged the pre-frame DOM would answer about a page
|
|
1175
|
+
// that no longer exists.
|
|
1176
|
+
if (
|
|
1177
|
+
startup &&
|
|
1178
|
+
(this._morphInFlight || this._pendingHtml != null || this._pendingExternal != null)
|
|
1179
|
+
) {
|
|
1180
|
+
this._retryStartup(attempt);
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1104
1183
|
const epoch = this._saveEpoch;
|
|
1184
|
+
const startGen = this._startGen;
|
|
1185
|
+
const seenSeq = this.lastSeenSeq;
|
|
1186
|
+
const applyGen = this._applyGen;
|
|
1187
|
+
const fetchId = ++this._servedFetchId;
|
|
1105
1188
|
if (repair && typeof seq !== 'number') seq = this._lastExternalSeq;
|
|
1106
1189
|
fetch(new URL(window.location.href), { cache: 'no-store' })
|
|
1107
1190
|
.then((response) => (response.ok ? response.text() : null))
|
|
1108
1191
|
.then((html) => {
|
|
1109
1192
|
if (this.isDestroyed || html == null) return;
|
|
1110
|
-
if (
|
|
1193
|
+
if (this._startGen !== startGen) return;
|
|
1194
|
+
const stamp = readServedStamp(html);
|
|
1195
|
+
// A version question is asked by the startup check and by a repair whose
|
|
1196
|
+
// body names its own version. Every other fetch — including an ordinary
|
|
1197
|
+
// fallback whose body happens to carry a stamp — is content, and keeps
|
|
1198
|
+
// the hold/retry behavior the external lane has always had.
|
|
1199
|
+
const versionCheck = startup || (repair && Boolean(stamp));
|
|
1200
|
+
if (versionCheck) {
|
|
1201
|
+
// A newer GET owns this question: whatever it is reading describes a
|
|
1202
|
+
// later moment than this response does. Except for a repair, which is
|
|
1203
|
+
// the only answer a page the server could not replay has: the newer
|
|
1204
|
+
// GET may yet fail, and dropping this body would leave nothing to ask
|
|
1205
|
+
// again. Refetch it at the watermark the page has reached instead,
|
|
1206
|
+
// under the same bounded count the retry always had.
|
|
1207
|
+
if (fetchId !== this._servedFetchId) {
|
|
1208
|
+
if (repair) {
|
|
1209
|
+
this._retryStartup(
|
|
1210
|
+
attempt + 1,
|
|
1211
|
+
{ repair, startup },
|
|
1212
|
+
typeof seq === 'number' ? Math.max(seq, this._lastExternalSeq) : seq
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
// The page moved while the GET was in flight — an own save, a frame,
|
|
1218
|
+
// or a morph. The comparison this check exists to make is against a
|
|
1219
|
+
// stationary page, so ask again rather than judge a version the tab has
|
|
1220
|
+
// already left. A failed startup GET is the one exception below: it
|
|
1221
|
+
// changes nothing and has nothing to retry toward.
|
|
1222
|
+
if (
|
|
1223
|
+
epoch !== this._saveEpoch ||
|
|
1224
|
+
seenSeq !== this.lastSeenSeq ||
|
|
1225
|
+
applyGen !== this._applyGen ||
|
|
1226
|
+
this._morphInFlight ||
|
|
1227
|
+
this._pendingHtml != null ||
|
|
1228
|
+
this._pendingExternal != null
|
|
1229
|
+
) {
|
|
1230
|
+
this._retryStartup(attempt + 1, { repair, startup }, seq);
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
if (!stamp) {
|
|
1234
|
+
// No stamp on the body means the host did not answer the version
|
|
1235
|
+
// question, and an answer nobody gave must not be invented: a
|
|
1236
|
+
// startup check keeps exactly the stamp it holds and applies
|
|
1237
|
+
// nothing, fetched or forgotten. A repair still has a page the
|
|
1238
|
+
// server could not replay, so it keeps its unstamped fallback.
|
|
1239
|
+
if (!repair) return;
|
|
1240
|
+
} else if (stamp === representedEtag()) {
|
|
1241
|
+
// The file this tab was served, and nothing to do about it.
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
} else if (typeof seq === 'number' && seq < this._lastExternalSeq) {
|
|
1111
1245
|
// A newer external change superseded this one, and its own fetch will
|
|
1112
1246
|
// queue a body. Except for a repair: that one exists because the server
|
|
1113
1247
|
// said replay cannot fix this page, so if the newer fetch fails there is
|
|
1114
1248
|
// nothing else coming. Refetch rather than drop the only repair.
|
|
1115
1249
|
if (repair && attempt < 3) {
|
|
1116
|
-
this._fetchServedDocument(this._lastExternalSeq, { attempt: attempt + 1, repair });
|
|
1250
|
+
this._fetchServedDocument(this._lastExternalSeq, { attempt: attempt + 1, repair, startup });
|
|
1117
1251
|
}
|
|
1118
1252
|
return;
|
|
1119
|
-
}
|
|
1120
|
-
if (this._saveEpoch > epoch) {
|
|
1253
|
+
} else if (this._saveEpoch > epoch) {
|
|
1121
1254
|
// An own save landed while the GET was in flight, so this body may
|
|
1122
1255
|
// predate it. Save-response order proves nothing about disk-write
|
|
1123
1256
|
// order — refetch for the newest bytes instead of dropping.
|
|
1124
1257
|
if (attempt < 3) {
|
|
1125
1258
|
console.log('[LiveSync] Refetching external change: own save landed mid-fetch');
|
|
1126
|
-
this._fetchServedDocument(seq, { attempt: attempt + 1, repair });
|
|
1259
|
+
this._fetchServedDocument(seq, { attempt: attempt + 1, repair, startup });
|
|
1127
1260
|
}
|
|
1128
1261
|
return;
|
|
1129
1262
|
}
|
|
1130
|
-
//
|
|
1131
|
-
//
|
|
1132
|
-
//
|
|
1133
|
-
//
|
|
1134
|
-
|
|
1263
|
+
// The stamp, when the body carries one, is the version of THESE bytes:
|
|
1264
|
+
// the same claim the navigation made about its own response, read from
|
|
1265
|
+
// the body rather than from discovery, which answers about a later
|
|
1266
|
+
// moment than any response. A body without one keeps the old fallback
|
|
1267
|
+
// — the apply leaves the stamp alone and etag.js asks the host. No
|
|
1268
|
+
// author either way, for the same reason: a GET answers what disk holds,
|
|
1269
|
+
// not who put it there.
|
|
1270
|
+
this._pendingExternal = {
|
|
1271
|
+
html,
|
|
1272
|
+
seq,
|
|
1273
|
+
saveEpoch: epoch,
|
|
1274
|
+
etag: stamp,
|
|
1275
|
+
by: null,
|
|
1276
|
+
// Why this body was fetched, kept beside it so the drain and the apply
|
|
1277
|
+
// can still tell a version check from an ordinary change. A body that
|
|
1278
|
+
// named its own version was judged against this tab's version, and the
|
|
1279
|
+
// drain must go on judging it that way. The epoch refetch below
|
|
1280
|
+
// re-issues a fetch whose caller may have been a repair.
|
|
1281
|
+
fetchOptions: { repair, startup, attempt, versionCheck },
|
|
1282
|
+
// What the check was made against, so the drain can tell whether the
|
|
1283
|
+
// answer it holds still describes the page that asked.
|
|
1284
|
+
startGen,
|
|
1285
|
+
seenSeq,
|
|
1286
|
+
applyGen,
|
|
1287
|
+
fetchId,
|
|
1288
|
+
};
|
|
1135
1289
|
this._scheduleNextFrame();
|
|
1136
1290
|
})
|
|
1137
1291
|
.catch((err) => {
|
|
1138
1292
|
this._log('External-change fetch failed', err);
|
|
1139
1293
|
// The watermark already advanced for this seq; leaving it there
|
|
1140
1294
|
// would drop the change forever. Roll back so a replay or a later
|
|
1141
|
-
// duplicate can redeliver it.
|
|
1295
|
+
// duplicate can redeliver it. A startup check has no seq to roll back
|
|
1296
|
+
// and no state to restore: it simply did not happen.
|
|
1142
1297
|
if (typeof seq === 'number' && this._lastExternalSeq === seq) {
|
|
1143
1298
|
this._lastExternalSeq = seq - 1;
|
|
1144
1299
|
}
|
|
1145
1300
|
});
|
|
1146
1301
|
}
|
|
1147
1302
|
|
|
1303
|
+
/**
|
|
1304
|
+
* Ask the served-document question again, coalesced and bounded.
|
|
1305
|
+
*
|
|
1306
|
+
* A version check that finds the page mid-morph, mid-queue or mid-change cannot
|
|
1307
|
+
* answer, and the answer is the whole point of the fetch, so it re-asks. The
|
|
1308
|
+
* single timer means three arrivals in one frame collapse to one re-ask, and
|
|
1309
|
+
* `attempt` counts only the re-asks that got as far as a GET, so a page that
|
|
1310
|
+
* never settles stops asking rather than looping forever.
|
|
1311
|
+
*
|
|
1312
|
+
* The re-ask carries the options and seq of the fetch that could not answer,
|
|
1313
|
+
* so a re-asked repair is still a repair and still owns its cursor seq.
|
|
1314
|
+
*
|
|
1315
|
+
* @param {number} [attempt]
|
|
1316
|
+
* @param {Object} [options] - The original fetch options.
|
|
1317
|
+
* @param {number} [seq]
|
|
1318
|
+
*/
|
|
1319
|
+
_retryStartup(attempt = 0, options = { startup: true }, seq) {
|
|
1320
|
+
if (this.isDestroyed || attempt > 3) return;
|
|
1321
|
+
clearTimeout(this._startupRetry);
|
|
1322
|
+
this._startupRetry = setTimeout(() => {
|
|
1323
|
+
this._startupRetry = null;
|
|
1324
|
+
if (this.isDestroyed) return;
|
|
1325
|
+
if (this._morphInFlight || this._pendingHtml != null || this._pendingExternal != null) {
|
|
1326
|
+
this._retryStartup(attempt, options, seq);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
this._fetchServedDocument(seq, { ...options, attempt });
|
|
1330
|
+
}, 16);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1148
1333
|
/**
|
|
1149
1334
|
* Apply an update received from the server. Morphs the entire document.
|
|
1150
1335
|
*
|
|
@@ -1199,9 +1384,21 @@ class LiveSync {
|
|
|
1199
1384
|
if (this.isDestroyed) return;
|
|
1200
1385
|
|
|
1201
1386
|
const ext = this._pendingExternal;
|
|
1387
|
+
const opts = ext?.fetchOptions || null;
|
|
1388
|
+
// A startup check and a stamped repair are both version checks: each one
|
|
1389
|
+
// judges its body against the version this tab represents, and each one
|
|
1390
|
+
// answers "nothing to do" when the two agree. An ordinary fetch, and an
|
|
1391
|
+
// unstamped repair, keep the pre-existing content behavior.
|
|
1392
|
+
const versionChecked = Boolean(opts?.startup || opts?.versionCheck);
|
|
1202
1393
|
let runExternal = false;
|
|
1203
1394
|
if (ext != null) {
|
|
1204
|
-
if (this._pendingHtml
|
|
1395
|
+
if (versionChecked && this._pendingHtml != null) {
|
|
1396
|
+
// A version check and a peer frame have no order to compare: the peer
|
|
1397
|
+
// slot's seq is a subscription cursor, not the version of the bytes it
|
|
1398
|
+
// carries, and the answer is only worth anything once the frame it
|
|
1399
|
+
// would be judging has landed. Drain the peer first and ask again.
|
|
1400
|
+
runExternal = false;
|
|
1401
|
+
} else if (this._pendingHtml == null) {
|
|
1205
1402
|
runExternal = true;
|
|
1206
1403
|
} else {
|
|
1207
1404
|
runExternal = !(
|
|
@@ -1215,20 +1412,59 @@ class LiveSync {
|
|
|
1215
1412
|
if (runExternal) {
|
|
1216
1413
|
this._pendingExternal = null;
|
|
1217
1414
|
// Stale-at-drain checks: a newer external change already superseded
|
|
1218
|
-
// this frame,
|
|
1415
|
+
// this frame, our own save landed after it was queued, or the page moved
|
|
1416
|
+
// past what a version check was made against.
|
|
1219
1417
|
if (typeof ext.seq === 'number' && ext.seq < this._lastExternalSeq) {
|
|
1220
|
-
|
|
1221
|
-
|
|
1418
|
+
if (versionChecked && opts?.repair) {
|
|
1419
|
+
// The repair is the only answer a page the server could not replay
|
|
1420
|
+
// has, and the newer change's own fetch may yet fail. Ask again once
|
|
1421
|
+
// that content has drained instead of dropping it here — at the
|
|
1422
|
+
// current watermark, since the seq this body carried is already
|
|
1423
|
+
// superseded and a re-ask that kept it would only be dropped again.
|
|
1424
|
+
this._retryStartup((opts.attempt || 0) + 1, opts, this._lastExternalSeq);
|
|
1425
|
+
} else {
|
|
1426
|
+
this._log('Dropping superseded external change at drain');
|
|
1427
|
+
}
|
|
1428
|
+
} else if (versionChecked && ext.startGen !== this._startGen) {
|
|
1429
|
+
// The run that asked is gone: this body answers nobody.
|
|
1430
|
+
this._log('Dropping superseded startup check at drain');
|
|
1431
|
+
} else if (versionChecked && ext.fetchId !== this._servedFetchId) {
|
|
1432
|
+
// A newer GET is already re-reading the document, so this body is
|
|
1433
|
+
// superseded — unless it is a repair, the one answer a page the server
|
|
1434
|
+
// could not replay has. The newer GET may yet fail, so ask again at the
|
|
1435
|
+
// watermark the page has reached rather than discard it; the bounded
|
|
1436
|
+
// count stops a page under constant churn.
|
|
1437
|
+
if (opts?.repair) {
|
|
1438
|
+
this._retryStartup((opts.attempt || 0) + 1, opts, this._lastExternalSeq);
|
|
1439
|
+
} else {
|
|
1440
|
+
this._log('Dropping superseded startup check at drain');
|
|
1441
|
+
}
|
|
1442
|
+
} else if (
|
|
1443
|
+
versionChecked &&
|
|
1444
|
+
(ext.saveEpoch !== this._saveEpoch ||
|
|
1445
|
+
ext.seenSeq !== this.lastSeenSeq ||
|
|
1446
|
+
ext.applyGen !== this._applyGen)
|
|
1447
|
+
) {
|
|
1448
|
+
// The page changed after this check was made. Comparing the version it
|
|
1449
|
+
// holds against a page that has since moved would answer about neither,
|
|
1450
|
+
// so ask again rather than apply a body that may already be obsolete.
|
|
1451
|
+
this._retryStartup((opts.attempt || 0) + 1, opts, ext.seq);
|
|
1452
|
+
} else if (versionChecked && ext.etag != null && ext.etag === representedEtag()) {
|
|
1453
|
+
// Checked once more at the moment of truth, because a frame that applied
|
|
1454
|
+
// while this waited can have brought the very version disk holds.
|
|
1455
|
+
this._log('Startup check found the same version: nothing to apply');
|
|
1456
|
+
} else if (!versionChecked && ext.saveEpoch !== this._saveEpoch) {
|
|
1222
1457
|
// The epoch moved, but save-response order does not prove disk-write
|
|
1223
1458
|
// order: the frame's content may still be newer than our save.
|
|
1224
1459
|
// Refetch the served document — applying what disk holds NOW is
|
|
1225
|
-
// always safe — instead of dropping the frame.
|
|
1460
|
+
// always safe — instead of dropping the frame. Mode rides along: a
|
|
1461
|
+
// repair that comes back this way is still a repair.
|
|
1226
1462
|
console.log('[LiveSync] Refetching external change: own save landed after queue');
|
|
1227
|
-
this._fetchServedDocument(ext.seq);
|
|
1463
|
+
this._fetchServedDocument(ext.seq, opts || {});
|
|
1228
1464
|
} else {
|
|
1229
1465
|
this._morphInFlight = true;
|
|
1230
1466
|
try {
|
|
1231
|
-
await this._doApplyExternal(ext.html, ext.seq, ext.etag, ext.by);
|
|
1467
|
+
await this._doApplyExternal(ext.html, ext.seq, ext.etag, ext.by, opts);
|
|
1232
1468
|
} catch (err) {
|
|
1233
1469
|
console.error('[LiveSync] applyExternal failed:', err);
|
|
1234
1470
|
} finally {
|
|
@@ -1339,6 +1575,19 @@ class LiveSync {
|
|
|
1339
1575
|
* @param {object} [lane.extra] - extra mergeDocument options (the disk lane's beforeApply)
|
|
1340
1576
|
*/
|
|
1341
1577
|
async _mergeIncoming(html, identityMap, { base, baseIdentityMap = null, captureLocal, synthetic, source = 'peer', seq = null, etag = null, extra = {} }) {
|
|
1578
|
+
let lineageCapture;
|
|
1579
|
+
const mergeDocument = (options) => {
|
|
1580
|
+
lineageCapture = beginLineageCapture(conflicts.list());
|
|
1581
|
+
try {
|
|
1582
|
+
const pending = HyperMorph.mergeDocument({ ...options, lineage: lineageCapture.lineage });
|
|
1583
|
+
lineageCapture.returned();
|
|
1584
|
+
return pending;
|
|
1585
|
+
} catch (error) {
|
|
1586
|
+
lineageCapture.invalidate(conflicts.list());
|
|
1587
|
+
lineageCapture.finish(conflicts.list());
|
|
1588
|
+
throw error;
|
|
1589
|
+
}
|
|
1590
|
+
};
|
|
1342
1591
|
const store = this.identity;
|
|
1343
1592
|
// A frame may neither write these onto our root nor, by not carrying them,
|
|
1344
1593
|
// take ours away. Returning false is hyper-morph's veto for both directions.
|
|
@@ -1396,7 +1645,7 @@ class LiveSync {
|
|
|
1396
1645
|
let localRoot = null;
|
|
1397
1646
|
let applyId = null;
|
|
1398
1647
|
if (base == null) {
|
|
1399
|
-
pending =
|
|
1648
|
+
pending = mergeDocument({
|
|
1400
1649
|
...common,
|
|
1401
1650
|
base: null,
|
|
1402
1651
|
local: { root: captureLocal(), toLive: originalSnapshotNode },
|
|
@@ -1414,7 +1663,7 @@ class LiveSync {
|
|
|
1414
1663
|
const localRoot = captureLocal();
|
|
1415
1664
|
const baseDoc = localRoot.ownerDocument;
|
|
1416
1665
|
if (baseDoc.documentElement !== localRoot) baseDoc.replaceChild(localRoot, baseDoc.documentElement);
|
|
1417
|
-
pending =
|
|
1666
|
+
pending = mergeDocument({
|
|
1418
1667
|
...common,
|
|
1419
1668
|
base: baseDoc,
|
|
1420
1669
|
local: { root: localRoot, toLive: originalSnapshotNode },
|
|
@@ -1432,7 +1681,7 @@ class LiveSync {
|
|
|
1432
1681
|
domain: source === 'disk' ? 'save' : 'sync',
|
|
1433
1682
|
});
|
|
1434
1683
|
try {
|
|
1435
|
-
pending =
|
|
1684
|
+
pending = mergeDocument({
|
|
1436
1685
|
...common,
|
|
1437
1686
|
base,
|
|
1438
1687
|
local: { root: localRoot, toLive: originalSnapshotNode },
|
|
@@ -1457,7 +1706,12 @@ class LiveSync {
|
|
|
1457
1706
|
try {
|
|
1458
1707
|
report = await pending;
|
|
1459
1708
|
} catch (err) {
|
|
1460
|
-
|
|
1709
|
+
lineageCapture.invalidate(conflicts.list());
|
|
1710
|
+
try {
|
|
1711
|
+
if (applyId) failApply(applyId, err, { ticket });
|
|
1712
|
+
} finally {
|
|
1713
|
+
lineageCapture.finish(conflicts.list());
|
|
1714
|
+
}
|
|
1461
1715
|
throw err;
|
|
1462
1716
|
}
|
|
1463
1717
|
const typedDuringWait = gateCaptureToken().gen !== token.gen;
|
|
@@ -1465,7 +1719,13 @@ class LiveSync {
|
|
|
1465
1719
|
// What the frame won over this tab's unsaved edits goes to the ledger with
|
|
1466
1720
|
// the clone the merge read as "mine". Earlier losses live in the ledger,
|
|
1467
1721
|
// not in the page's dirty state.
|
|
1468
|
-
|
|
1722
|
+
let conflictIds;
|
|
1723
|
+
try {
|
|
1724
|
+
lineageCapture.prepareComplete(report, conflicts.list());
|
|
1725
|
+
conflictIds = applyId ? completeApply(applyId, report.conflicts, { ticket }) : [];
|
|
1726
|
+
} finally {
|
|
1727
|
+
lineageCapture.finish(conflicts.list());
|
|
1728
|
+
}
|
|
1469
1729
|
if (dirty && !report.localDiverged) {
|
|
1470
1730
|
gateClearIfUnchanged(token);
|
|
1471
1731
|
}
|
|
@@ -1598,9 +1858,16 @@ class LiveSync {
|
|
|
1598
1858
|
// above, and deliberately the same moment: the two claims a page makes
|
|
1599
1859
|
// when it adopts a stamp are "the host stores this version" and "I hold
|
|
1600
1860
|
// it", and only the second one is this tab's to make.
|
|
1601
|
-
if (!(this._acceptedSaveTicket > ticket)
|
|
1602
|
-
|
|
1603
|
-
|
|
1861
|
+
if (!(this._acceptedSaveTicket > ticket)) {
|
|
1862
|
+
if (typeof etag === 'string' && etag) {
|
|
1863
|
+
recordEtag(etag);
|
|
1864
|
+
stamped = true;
|
|
1865
|
+
} else {
|
|
1866
|
+
// Content nobody stamped: the DOM no longer holds the version this
|
|
1867
|
+
// tab was claiming, so the claim goes. A save accepted since this
|
|
1868
|
+
// frame captured keeps its own newer stamp.
|
|
1869
|
+
forgetRepresentedEtag();
|
|
1870
|
+
}
|
|
1604
1871
|
}
|
|
1605
1872
|
|
|
1606
1873
|
// Cross-lane baseline: the DOM now holds this frame's content, but the
|
|
@@ -1691,7 +1958,7 @@ class LiveSync {
|
|
|
1691
1958
|
* holds or a later dirty peer apply misclassifies this frame's content as
|
|
1692
1959
|
* local edits.
|
|
1693
1960
|
*/
|
|
1694
|
-
async _doApplyExternal(html, seq, etag = null, by = null) {
|
|
1961
|
+
async _doApplyExternal(html, seq, etag = null, by = null, fetchOptions = null) {
|
|
1695
1962
|
this._log(`applyExternal - external disk change (seq=${seq})`);
|
|
1696
1963
|
this.isPaused = true;
|
|
1697
1964
|
|
|
@@ -1719,6 +1986,26 @@ class LiveSync {
|
|
|
1719
1986
|
if (this.lane === 'live' && pageMaybeDirty() && this._diskBase === null) {
|
|
1720
1987
|
console.log('[LiveSync] Holding external change: unsaved local edits and no baseline to merge against');
|
|
1721
1988
|
this._setHeld('external', true, null);
|
|
1989
|
+
// A version check holds nothing: it has no frame to re-queue, and the
|
|
1990
|
+
// body it fetched describes disk as of its GET rather than a version
|
|
1991
|
+
// anyone named. Re-queueing those bytes would apply them over an edit
|
|
1992
|
+
// the person has not saved yet, and could even put a version back over
|
|
1993
|
+
// one a newer peer has already passed. So the hold takes the ordinary
|
|
1994
|
+
// three-second wait and then fetches afresh: whatever disk holds by then
|
|
1995
|
+
// is answered against the page as it stands by then. The hold is not a
|
|
1996
|
+
// retry attempt, so a person who stays dirty never exhausts the bounded
|
|
1997
|
+
// retries; the options ride along so a repair is still a repair.
|
|
1998
|
+
if (fetchOptions?.startup || fetchOptions?.versionCheck) {
|
|
1999
|
+
clearTimeout(this._holdRetryExt);
|
|
2000
|
+
this._holdRetryExt = setTimeout(() => {
|
|
2001
|
+
this._holdRetryExt = null;
|
|
2002
|
+
if (this.isDestroyed) return;
|
|
2003
|
+
const currentSeq =
|
|
2004
|
+
typeof seq === 'number' ? Math.max(seq, this._lastExternalSeq) : seq;
|
|
2005
|
+
this._retryStartup(0, fetchOptions, currentSeq);
|
|
2006
|
+
}, 3000);
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
1722
2009
|
const epochAtHold = this._saveEpoch;
|
|
1723
2010
|
clearTimeout(this._holdRetryExt);
|
|
1724
2011
|
this._holdRetryExt = setTimeout(() => {
|
|
@@ -1760,12 +2047,18 @@ class LiveSync {
|
|
|
1760
2047
|
// bytes, and the convergence save at the bottom needs a stamp the host
|
|
1761
2048
|
// will accept or the merge is refused and lost.
|
|
1762
2049
|
//
|
|
1763
|
-
// A frame with no stamp (an older host, or the content-less fetch fallback
|
|
1764
|
-
//
|
|
1765
|
-
// etag.js falls back to asking the host.
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
2050
|
+
// A frame with no stamp (an older host, or the content-less fetch fallback
|
|
2051
|
+
// on a host that does not answer the version question) leaves this alone,
|
|
2052
|
+
// and the listener in etag.js falls back to asking the host. A save
|
|
2053
|
+
// accepted since this frame captured already recorded a newer stamp, and
|
|
2054
|
+
// this body, older than that save, must not rewind it — nor clear it.
|
|
2055
|
+
if (!(this._acceptedSaveTicket > ticket)) {
|
|
2056
|
+
if (typeof etag === 'string' && etag) {
|
|
2057
|
+
recordEtag(etag);
|
|
2058
|
+
stamped = true;
|
|
2059
|
+
} else {
|
|
2060
|
+
forgetRepresentedEtag();
|
|
2061
|
+
}
|
|
1769
2062
|
}
|
|
1770
2063
|
|
|
1771
2064
|
if (
|
package/src/sync/stream.js
CHANGED
|
@@ -3,7 +3,7 @@ const HIDDEN_DELAY = 5000;
|
|
|
3
3
|
const PING_INTERVAL = 15000;
|
|
4
4
|
|
|
5
5
|
export class SyncStream extends EventTarget {
|
|
6
|
-
constructor(url, { shared = false, documentURL, lane } = {}) {
|
|
6
|
+
constructor(url, { shared = false, documentURL, lane, startupCheck = false } = {}) {
|
|
7
7
|
super();
|
|
8
8
|
this.url = url;
|
|
9
9
|
this.readyState = 0;
|
|
@@ -16,7 +16,14 @@ export class SyncStream extends EventTarget {
|
|
|
16
16
|
this._since = 0;
|
|
17
17
|
this._closed = false;
|
|
18
18
|
this._suspended = false;
|
|
19
|
+
// Two different questions, asked once each at the first subscription and
|
|
20
|
+
// never again: `_repair` is a real reconnect whose replay the host could not
|
|
21
|
+
// retain, and `_startup` is the caller's own check of a page that was served
|
|
22
|
+
// before this stream existed. Only the caller can know whether the response
|
|
23
|
+
// it was served named a version, so the startup check is opt-in and off by
|
|
24
|
+
// default: a stream nobody told about provenance must not manufacture one.
|
|
19
25
|
this._repair = false;
|
|
26
|
+
this._startup = startupCheck;
|
|
20
27
|
this._worker = null;
|
|
21
28
|
this._source = null;
|
|
22
29
|
this._visibility = () => {
|
|
@@ -77,8 +84,11 @@ export class SyncStream extends EventTarget {
|
|
|
77
84
|
} else if (data.type === 'cursor') {
|
|
78
85
|
clearTimeout(this._startTimer);
|
|
79
86
|
if (Number.isSafeInteger(data.seq)) this._since = Math.max(this._since, data.seq);
|
|
80
|
-
|
|
87
|
+
const resync = data.resync === true || this._repair;
|
|
88
|
+
const startup = this._startup;
|
|
81
89
|
this._repair = false;
|
|
90
|
+
this._startup = false;
|
|
91
|
+
this._emit('cursor', JSON.stringify({ ...data, resync, startup }));
|
|
82
92
|
} else if (data.type === 'frame' && typeof data.data === 'string') {
|
|
83
93
|
this._remember(data.data);
|
|
84
94
|
this._emit('message', data.data);
|
|
@@ -111,9 +121,12 @@ export class SyncStream extends EventTarget {
|
|
|
111
121
|
if (this._source !== source) return;
|
|
112
122
|
this.readyState = 1;
|
|
113
123
|
this._emit('open');
|
|
114
|
-
if (this._repair) {
|
|
124
|
+
if (this._repair || this._startup) {
|
|
125
|
+
const resync = this._repair;
|
|
126
|
+
const startup = this._startup;
|
|
115
127
|
this._repair = false;
|
|
116
|
-
this.
|
|
128
|
+
this._startup = false;
|
|
129
|
+
this._emit('cursor', JSON.stringify({ resync, startup }));
|
|
117
130
|
}
|
|
118
131
|
};
|
|
119
132
|
source.onerror = () => {
|
package/src/ui/bevel-controls.js
CHANGED
|
@@ -72,8 +72,10 @@ function protectIcon(svg, size) {
|
|
|
72
72
|
`fill:${attr('fill', svg.getAttribute('fill') ?? 'currentColor')}`,
|
|
73
73
|
`stroke:${attr('stroke', svg.getAttribute('stroke') ?? 'none')}`,
|
|
74
74
|
];
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
for (const prop of ['stroke-width', 'stroke-linecap', 'stroke-linejoin']) {
|
|
76
|
+
const value = el.getAttribute(prop) ?? svg.getAttribute(prop);
|
|
77
|
+
if (value !== null) rules.push(`${prop}:${value}`);
|
|
78
|
+
}
|
|
77
79
|
if (el.localName === 'path' && el.hasAttribute('d')) rules.push(`d:path("${el.getAttribute('d')}")`);
|
|
78
80
|
style(el, rules);
|
|
79
81
|
}
|
|
@@ -84,6 +86,7 @@ export function bevelButton(label, { variant = 'default', small = false, onClick
|
|
|
84
86
|
const size = small ? SIZE.small : SIZE.normal;
|
|
85
87
|
const reducedMotion = media('(prefers-reduced-motion: reduce)');
|
|
86
88
|
const forced = media('(forced-colors: active)');
|
|
89
|
+
const coarse = media('(pointer: coarse)');
|
|
87
90
|
const flags = { hovered: false, pressed: false, focusVisible: false, disabled: false };
|
|
88
91
|
const pinned = new Map();
|
|
89
92
|
|
|
@@ -103,6 +106,7 @@ export function bevelButton(label, { variant = 'default', small = false, onClick
|
|
|
103
106
|
}
|
|
104
107
|
if (flags.focusVisible) rules.push(...RULES.focus);
|
|
105
108
|
if (forced) rules.push(...MEDIA.forcedColors);
|
|
109
|
+
if (coarse) rules.push(...MEDIA.coarseButton);
|
|
106
110
|
rules.push(...extra);
|
|
107
111
|
for (const [prop, value] of pinned) rules.push(`${prop}:${value}`);
|
|
108
112
|
restyle(b, rules);
|
|
@@ -234,6 +238,7 @@ export function bevelText(tag, rules = [], text) {
|
|
|
234
238
|
export function paintInput(el, { rules = [] } = {}) {
|
|
235
239
|
const flags = { hovered: false, focused: false };
|
|
236
240
|
const forced = media('(forced-colors: active)');
|
|
241
|
+
const coarse = media('(pointer: coarse)');
|
|
237
242
|
runtime(el);
|
|
238
243
|
function applyState() {
|
|
239
244
|
const height = el.localName === 'textarea' ? el.style.getPropertyValue('height') : '';
|
|
@@ -242,6 +247,7 @@ export function paintInput(el, { rules = [] } = {}) {
|
|
|
242
247
|
if (flags.focused) list.push(...RULES.inputFocus);
|
|
243
248
|
if (forced) list.push(...MEDIA.forcedColors);
|
|
244
249
|
list.push(...rules);
|
|
250
|
+
if (coarse) list.push(...MEDIA.coarseInput);
|
|
245
251
|
if (height) list.push(`height:${height}`);
|
|
246
252
|
restyle(el, list);
|
|
247
253
|
}
|