@panphora/clayjs 1.2.0 → 1.4.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.
Files changed (42) hide show
  1. package/README.md +7 -3
  2. package/THIRD-PARTY-NOTICES.md +10 -0
  3. package/dist/clay.standalone.js +22539 -14260
  4. package/entries/clay-data.js +1 -1
  5. package/entries/sap.js +1 -1
  6. package/package.json +7 -2
  7. package/packed-contract.json +17 -0
  8. package/src/attrs/save-freeze.js +10 -18
  9. package/src/core/admin-contenteditable.js +8 -6
  10. package/src/core/admin-inputs.js +23 -13
  11. package/src/core/admin-onclick.js +5 -0
  12. package/src/core/is-edit-mode.js +7 -2
  13. package/src/core/persist.js +5 -10
  14. package/src/core/save-core.js +35 -0
  15. package/src/core/save.js +13 -1
  16. package/src/core/snapshot.js +186 -14
  17. package/src/core/source-map.js +1025 -0
  18. package/src/core/unsaved-warning.js +3 -0
  19. package/src/dom/dom-helpers.js +5 -1
  20. package/src/lib/content-dom.js +108 -0
  21. package/src/lib/mutation.js +26 -3
  22. package/src/lib/region-capabilities.js +69 -0
  23. package/src/lib/region-policy.js +18 -13
  24. package/src/loader-logic.js +27 -5
  25. package/src/loader.js +6 -0
  26. package/src/plugins/ai-edit.js +625 -0
  27. package/src/plugins/demo.js +3 -0
  28. package/src/plugins/sortable.js +6 -1
  29. package/src/plugins/source.js +410 -0
  30. package/src/plugins/wire.js +248 -47
  31. package/src/sync/live-sync.js +106 -42
  32. package/src/sync/presence.js +303 -0
  33. package/src/sync/section-notice.js +230 -0
  34. package/src/sync/splice-merge.js +7 -10
  35. package/src/sync/stream.js +190 -0
  36. package/src/vendor/control-serialize.vendor.js +10 -6
  37. package/src/vendor/hyper-morph.vendor.js +2 -2
  38. package/src/vendor/hyper-undo.vendor.js +1 -1
  39. package/src/vendor/hypercms.vendor.js +438 -45
  40. package/src/vendor/parse5.vendor.js +3 -0
  41. package/src/vendor/quickcrop.vendor.js +1 -1
  42. package/src/vendor/richclay.vendor.js +22 -15
@@ -1,14 +1,15 @@
1
1
  import { isEditMode } from "../core/is-edit-mode.js";
2
+ import { saveToken } from "../core/host-attrs.js";
3
+ import { hostMeta, hostSupports } from "../core/host-meta.js";
2
4
 
3
5
  /**
4
6
  * clay.wire — a per-file control channel between this page and a local process.
5
7
  *
6
8
  * 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.
9
+ * in the user's own terminal answers with progress and a terminal frame. An
10
+ * editing process changes the file and the result reaches this page through
11
+ * live-sync. A structured helper can instead return a bounded result on the
12
+ * terminal frame without touching the document.
12
13
  *
13
14
  * Three constraints shape everything below.
14
15
  *
@@ -44,13 +45,14 @@ import { isEditMode } from "../core/is-edit-mode.js";
44
45
  // only has to say so.
45
46
  const ACK_TIMEOUT_MS = 15000;
46
47
 
47
- // After that, it is an inactivity deadline: every frame rearms it, and `wire
48
+ // Raw handlers keep an inactivity deadline: every frame rearms it, and `wire
48
49
  // 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.
50
+ // what it is doing keeps its request alive indefinitely. A structured handler
51
+ // instead announces its fixed execution budget in the acknowledgement.
52
52
  const SILENCE_TIMEOUT_MS = 120000;
53
53
 
54
+ const STRUCTURED_TIMEOUT_GRACE_MS = 15000;
55
+
54
56
  // `wire/done` means the handler finished writing the file. It does not mean this
55
57
  // page has rendered the change: that arrives on the live-sync lane after the
56
58
  // watcher's quiet interval, and nothing orders the two. So a finished request
@@ -72,6 +74,8 @@ const OPEN_STATES = new Set(["sent", "acked"]);
72
74
  // for a request the handler also errored.
73
75
  const TERMINAL_STATES = new Set(["done", "error", "cancelled"]);
74
76
 
77
+ const HELPER_EXTENSION = "wire";
78
+
75
79
  const records = new Map();
76
80
  const listeners = new Set();
77
81
 
@@ -94,13 +98,34 @@ function wireURL(path) {
94
98
  return new URL(path, window.location.origin).href;
95
99
  }
96
100
 
101
+ // The document's save token rides on both legs of the wire.
102
+ //
103
+ // A helper-bound channel is the one place "one wire per file" has to be
104
+ // isolation rather than addressing. A page names only its own URL, but the host
105
+ // resolves that through the same funnel the save route uses, and that funnel
106
+ // admits any registered path on this origin. Without the token, another
107
+ // document's page on the same loopback origin could subscribe to this file's
108
+ // channel and push requests that run this file's helpers.
109
+ //
110
+ // A host that has no helper bound to the file ignores the token on both legs, so
111
+ // this is unconditional here and costs a document nothing.
112
+ function wireToken() {
113
+ const token = saveToken();
114
+ return typeof token === "string" && token !== "" ? token : null;
115
+ }
116
+
97
117
  function view(rec) {
98
118
  return {
99
119
  id: rec.id,
100
120
  type: rec.type,
101
121
  state: rec.state,
122
+ helper: rec.helper,
102
123
  text: rec.text,
124
+ result: rec.result,
103
125
  error: rec.error,
126
+ errorCode: rec.errorCode,
127
+ errorDetails: rec.errorDetails,
128
+ errorSource: rec.errorSource,
104
129
  startedAt: rec.startedAt,
105
130
  };
106
131
  }
@@ -129,13 +154,14 @@ function setState(rec, state) {
129
154
  emit(rec);
130
155
  }
131
156
 
132
- // The request deadline. Armed before the POST and rearmed by every frame, so a
133
- // request is bounded from end to end rather than only up to its ack.
134
- function arm(rec, ms, message) {
157
+ // The request deadline. Armed before the POST, then replaced by the handler's
158
+ // raw inactivity window or structured execution budget when it acknowledges.
159
+ function arm(rec, ms, message, { cancelFirst = false, errorCode = null } = {}) {
135
160
  clearTimeout(rec.timer);
136
161
  rec.timer = setTimeout(() => {
137
162
  rec.timer = null;
138
- finish(rec, "error", message);
163
+ if (cancelFirst) postCancel(rec);
164
+ finish(rec, "error", message, { errorCode, errorSource: "client" });
139
165
  }, ms);
140
166
  }
141
167
 
@@ -162,7 +188,11 @@ function prune() {
162
188
  function openStream() {
163
189
  if (streamReady) return streamReady;
164
190
 
165
- const path = `/_/wire/subscribe?page-url=${encodeURIComponent(window.location.href)}`;
191
+ // In the query, not a header: native EventSource takes no custom headers, and
192
+ // the access log records URL.Path without the query.
193
+ const token = wireToken();
194
+ const path = `/_/wire/subscribe?page-url=${encodeURIComponent(window.location.href)}`
195
+ + (token ? `&token=${encodeURIComponent(token)}` : "");
166
196
  stream = new EventSource(wireURL(path));
167
197
 
168
198
  stream.onmessage = (event) => {
@@ -222,26 +252,88 @@ function handleFrame(frame) {
222
252
  // Rearmed, not cleared. The handler acknowledges within milliseconds of
223
253
  // picking a request up, so clearing here would leave every working request
224
254
  // with no deadline at all.
225
- arm(rec, SILENCE_TIMEOUT_MS, "the agent stopped responding");
255
+ if (
256
+ frame.payload?.mode === "jsonl" &&
257
+ Number.isFinite(frame.payload.budgetMs) &&
258
+ frame.payload.budgetMs > 0
259
+ ) {
260
+ rec.structured = true;
261
+ arm(
262
+ rec,
263
+ frame.payload.budgetMs + STRUCTURED_TIMEOUT_GRACE_MS,
264
+ "the helper did not finish within its execution budget",
265
+ { cancelFirst: true, errorCode: "helper_timeout" }
266
+ );
267
+ } else {
268
+ arm(rec, SILENCE_TIMEOUT_MS, "the agent stopped responding");
269
+ }
226
270
  setState(rec, "acked");
227
271
  break;
228
272
  case "wire/status":
229
- arm(rec, SILENCE_TIMEOUT_MS, "the agent stopped responding");
273
+ if (!rec.structured) arm(rec, SILENCE_TIMEOUT_MS, "the agent stopped responding");
230
274
  rec.text = typeof frame.text === "string" ? frame.text : "";
231
275
  rec.state = "acked";
276
+ notifyStatus(rec, frame);
232
277
  emit(rec, frame);
233
278
  break;
234
279
  case "wire/done":
235
- land(rec);
280
+ rec.result = Object.prototype.hasOwnProperty.call(frame, "payload") ? frame.payload : null;
281
+ if (rec.document === "edit") land(rec);
282
+ else finish(rec, "done", null);
236
283
  break;
237
284
  case "wire/error":
238
- finish(rec, "error", frame.text || "the handler reported an error");
285
+ finishError(rec, frame);
239
286
  break;
240
287
  default:
241
288
  break;
242
289
  }
243
290
  }
244
291
 
292
+ function notifyStatus(rec, frame) {
293
+ if (!rec.onStatus || typeof frame.text !== "string") return;
294
+ const status = { text: frame.text };
295
+ if (
296
+ frame.payload &&
297
+ typeof frame.payload === "object" &&
298
+ Object.prototype.hasOwnProperty.call(frame.payload, "progress")
299
+ ) {
300
+ status.progress = frame.payload.progress;
301
+ }
302
+ try {
303
+ rec.onStatus(status);
304
+ } catch (err) {
305
+ console.error("clay.wire: an onStatus listener threw", err);
306
+ }
307
+ }
308
+
309
+ function finishError(rec, frame) {
310
+ const payload = frame.payload;
311
+ const structured =
312
+ payload &&
313
+ typeof payload === "object" &&
314
+ !Array.isArray(payload) &&
315
+ (payload.source === "application" || payload.source === "host") &&
316
+ typeof payload.code === "string" &&
317
+ payload.code;
318
+ const error = typeof frame.text === "string" && frame.text
319
+ ? frame.text
320
+ : "the handler reported an error";
321
+
322
+ if (!structured) {
323
+ finish(rec, "error", error);
324
+ return;
325
+ }
326
+
327
+ const state = payload.source === "host" && payload.code === "helper_cancelled"
328
+ ? "cancelled"
329
+ : "error";
330
+ finish(rec, state, error, {
331
+ errorCode: payload.code,
332
+ errorDetails: Object.prototype.hasOwnProperty.call(payload, "details") ? payload.details : null,
333
+ errorSource: payload.source,
334
+ });
335
+ }
336
+
245
337
  // --- the landing state -----------------------------------------------------
246
338
 
247
339
  // `clay:sync-applied` fires on every successful remote morph, from live-sync's
@@ -292,7 +384,7 @@ function land(rec) {
292
384
  closeStreamIfIdle();
293
385
  }
294
386
 
295
- function finish(rec, state, error) {
387
+ function finish(rec, state, error, fields = {}) {
296
388
  if (TERMINAL_STATES.has(rec.state)) return;
297
389
  const wasLanding = rec.state === "landing";
298
390
  disarm(rec);
@@ -306,7 +398,14 @@ function finish(rec, state, error) {
306
398
  unwatchLandingsIfIdle();
307
399
  }
308
400
  rec.error = error;
401
+ if (Object.prototype.hasOwnProperty.call(fields, "errorCode")) rec.errorCode = fields.errorCode;
402
+ if (Object.prototype.hasOwnProperty.call(fields, "errorDetails")) rec.errorDetails = fields.errorDetails;
403
+ if (Object.prototype.hasOwnProperty.call(fields, "errorSource")) rec.errorSource = fields.errorSource;
309
404
  rec.state = state;
405
+ if (rec.signal && rec.signalHandler) {
406
+ rec.signal.removeEventListener("abort", rec.signalHandler);
407
+ rec.signalHandler = null;
408
+ }
310
409
  releaseSaving(rec);
311
410
  emit(rec);
312
411
  closeStreamIfIdle();
@@ -432,12 +531,15 @@ async function flushSave() {
432
531
  // --- sending ---------------------------------------------------------------
433
532
 
434
533
  async function postFrame(body, signal) {
534
+ const headers = {
535
+ "Content-Type": "application/json",
536
+ "Page-URL": window.location.href,
537
+ };
538
+ const token = wireToken();
539
+ if (token) headers["Save-Token"] = token;
435
540
  const response = await fetch(wireURL("/_/wire/send"), {
436
541
  method: "POST",
437
- headers: {
438
- "Content-Type": "application/json",
439
- "Page-URL": window.location.href,
440
- },
542
+ headers,
441
543
  body: JSON.stringify(body),
442
544
  signal,
443
545
  });
@@ -460,42 +562,59 @@ async function postFrame(body, signal) {
460
562
  function send(payload, opts = {}) {
461
563
  const id = typeof opts.id === "string" && opts.id ? opts.id : newId();
462
564
  const type = typeof opts.type === "string" && opts.type ? opts.type : "wire/request";
565
+ const hasHelper = Object.prototype.hasOwnProperty.call(opts, "helper");
566
+ const helper = hasHelper && typeof opts.helper === "string" && opts.helper ? opts.helper : null;
567
+ const documentMode = opts.document === undefined ? (helper ? "none" : "edit") : opts.document;
568
+
569
+ if (hasHelper && !helper) {
570
+ return rejected(id, type, null, "helper must be a nonempty string", "invalid_helper");
571
+ }
572
+ if (documentMode !== "edit" && documentMode !== "none") {
573
+ return rejected(
574
+ id,
575
+ type,
576
+ helper,
577
+ 'document must be "edit" or "none"',
578
+ "invalid_document"
579
+ );
580
+ }
463
581
 
464
582
  // The id is request identity on the wire, on both sides: a reused one would
465
583
  // route the first request's frames to the second record, leaving the first
466
584
  // unresolvable and its save hold never released. Refused as an outcome rather
467
585
  // than thrown, so a UI renders it the way it renders any other failure.
468
586
  if (records.has(id)) {
469
- const clash = {
587
+ return rejected(
470
588
  id,
471
589
  type,
472
- state: "error",
473
- text: "",
474
- error: "a request with this id is already on the wire",
475
- startedAt: Date.now(),
476
- };
477
- return {
478
- id,
479
- get state() {
480
- return clash.state;
481
- },
482
- done: Promise.resolve(view(clash)),
483
- cancel: () => false,
484
- };
590
+ helper,
591
+ "a request with this id is already on the wire",
592
+ "duplicate_request"
593
+ );
485
594
  }
486
595
 
487
596
  const rec = {
488
597
  id,
489
598
  type,
490
599
  state: "sent",
600
+ helper,
601
+ document: documentMode,
602
+ structured: false,
603
+ onStatus: typeof opts.onStatus === "function" ? opts.onStatus : null,
491
604
  text: "",
605
+ result: null,
492
606
  error: null,
607
+ errorCode: null,
608
+ errorDetails: null,
609
+ errorSource: null,
493
610
  startedAt: Date.now(),
494
611
  timer: null,
495
612
  landingTimer: null,
496
613
  abort: typeof AbortController === "function" ? new AbortController() : null,
497
614
  holdsSave: false,
498
615
  settle: null,
616
+ signal: opts.signal && typeof opts.signal.addEventListener === "function" ? opts.signal : null,
617
+ signalHandler: null,
499
618
  };
500
619
  rec.done = new Promise((resolve) => {
501
620
  rec.settle = resolve;
@@ -503,15 +622,36 @@ function send(payload, opts = {}) {
503
622
  records.set(id, rec);
504
623
  emit(rec);
505
624
 
625
+ if (rec.signal) {
626
+ rec.signalHandler = () => cancel(id);
627
+ rec.signal.addEventListener("abort", rec.signalHandler, { once: true });
628
+ if (rec.signal.aborted) rec.signalHandler();
629
+ }
630
+
506
631
  const dispatch = async () => {
507
632
  // The record is re-checked after every await. A cancel can land in any of
508
633
  // these gaps, and a step that ran on regardless would open a stream nobody
509
634
  // closes or post a request the user already took back.
510
- await holdSaving(rec);
511
635
  if (rec.state !== "sent") return;
636
+ if (rec.helper) {
637
+ const supported = await hostSupports(HELPER_EXTENSION);
638
+ if (rec.state !== "sent") return;
639
+ if (!supported) {
640
+ finish(rec, "error", "this host does not support named wire helpers", {
641
+ errorCode: "helper_protocol_unsupported",
642
+ errorSource: "client",
643
+ });
644
+ return;
645
+ }
646
+ }
512
647
 
513
- await flushSave();
514
- if (rec.state !== "sent") return;
648
+ if (rec.document === "edit") {
649
+ await holdSaving(rec);
650
+ if (rec.state !== "sent") return;
651
+
652
+ await flushSave();
653
+ if (rec.state !== "sent") return;
654
+ }
515
655
 
516
656
  // Subscribe before posting. A handler can acknowledge in single-digit
517
657
  // milliseconds, a fresh subscription deliberately replays nothing, and only
@@ -527,13 +667,18 @@ function send(payload, opts = {}) {
527
667
  // the request unbounded, and an ack that arrives while the POST is still in
528
668
  // flight — the ordinary case, since the page subscribed first — would be
529
669
  // followed by a fresh ack timer that fails a healthy request 15s later.
530
- arm(rec, ACK_TIMEOUT_MS, "the agent never answered");
531
-
532
- const { ok, status, reply } = await postFrame(
533
- { type: rec.type, id: rec.id, text: opts.text, payload },
534
- rec.abort?.signal
670
+ arm(
671
+ rec,
672
+ ACK_TIMEOUT_MS,
673
+ "the agent never answered",
674
+ rec.helper ? { cancelFirst: true, errorCode: "ack_timeout" } : undefined
535
675
  );
536
676
 
677
+ const body = { type: rec.type, id: rec.id, text: opts.text, payload };
678
+ if (rec.helper) body.helper = rec.helper;
679
+ if (rec.helper || opts.document !== undefined) body.document = rec.document;
680
+ const { ok, status, reply } = await postFrame(body, rec.abort?.signal);
681
+
537
682
  // A frame may have moved this request on while its own POST was in flight.
538
683
  // Only a request still waiting on that POST may be failed by it.
539
684
  if (rec.state !== "sent") return;
@@ -542,6 +687,20 @@ function send(payload, opts = {}) {
542
687
  finish(rec, "error", `the wire refused this request (${status})`);
543
688
  return;
544
689
  }
690
+ // A typed refusal on the reply beats the generic reading of delivered: 0.
691
+ // The host sends the same refusal down the stream, but the reply and the
692
+ // stream are two connections with no ordering between them, so settling on
693
+ // delivered: 0 first would discard it and report "no agent is attached" for a
694
+ // handler that was attached and refused for a specific, reportable reason.
695
+ const refused = reply && reply.refused;
696
+ if (refused && typeof refused.code === "string" && refused.code) {
697
+ finish(rec, "error", refused.message || "the handler refused this request", {
698
+ errorCode: refused.code,
699
+ errorSource: refused.source === "application" ? "application" : "host",
700
+ errorDetails: null,
701
+ });
702
+ return;
703
+ }
545
704
  if (!reply || reply.delivered === 0) {
546
705
  // Accepted by the router and taken by nobody. Reporting this as an error
547
706
  // rather than a pending request is the difference between a UI that says
@@ -564,6 +723,30 @@ function send(payload, opts = {}) {
564
723
  };
565
724
  }
566
725
 
726
+ function rejected(id, type, helper, error, errorCode) {
727
+ const rec = {
728
+ id,
729
+ type,
730
+ state: "error",
731
+ helper,
732
+ text: "",
733
+ result: null,
734
+ error,
735
+ errorCode,
736
+ errorDetails: null,
737
+ errorSource: "client",
738
+ startedAt: Date.now(),
739
+ };
740
+ return {
741
+ id,
742
+ get state() {
743
+ return rec.state;
744
+ },
745
+ done: Promise.resolve(view(rec)),
746
+ cancel: () => false,
747
+ };
748
+ }
749
+
567
750
  /**
568
751
  * Stop completely: the request ends here, its late frames are ignored, and the
569
752
  * handler is asked to stop. A cancel that only hid the spinner would leave the
@@ -572,11 +755,15 @@ function send(payload, opts = {}) {
572
755
  function cancel(id) {
573
756
  const rec = records.get(id);
574
757
  if (!rec || !OPEN_STATES.has(rec.state)) return false;
575
- postFrame({ type: "wire/cancel", id }).catch(() => {});
758
+ postCancel(rec);
576
759
  finish(rec, "cancelled", null);
577
760
  return true;
578
761
  }
579
762
 
763
+ function postCancel(rec) {
764
+ postFrame({ type: "wire/cancel", id: rec.id }).catch(() => {});
765
+ }
766
+
580
767
  function get(id) {
581
768
  const rec = records.get(id);
582
769
  return rec ? view(rec) : undefined;
@@ -599,6 +786,20 @@ function on(fn) {
599
786
  return () => listeners.delete(fn);
600
787
  }
601
788
 
602
- export const wire = { send, cancel, get, list, isBusy, on };
789
+ async function helpers() {
790
+ const meta = await hostMeta({ fresh: true });
791
+ if (!meta.extensions.includes(HELPER_EXTENSION)) return [];
792
+ const declared = meta.document?.helpers;
793
+ if (!Array.isArray(declared)) return [];
794
+ return declared
795
+ .filter((helper) =>
796
+ helper &&
797
+ typeof helper.name === "string" &&
798
+ ["ready", "denied", "unavailable"].includes(helper.state)
799
+ )
800
+ .map(({ name, state }) => ({ name, state }));
801
+ }
802
+
803
+ export const wire = { send, cancel, get, list, isBusy, on, helpers };
603
804
 
604
805
  export default wire;